@openagents-org/agent-launcher 0.2.167 → 0.2.169
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/icons/deepseek.svg +1 -0
- package/package.json +1 -1
- package/registry.json +89 -0
- package/src/adapters/deepseek-runtime.js +634 -0
- package/src/adapters/deepseek.js +1190 -0
- package/src/adapters/index.js +4 -1
- package/src/adapters/utils.js +24 -4
- package/src/adapters/workspace-prompt.js +63 -0
- package/src/installer.js +60 -10
- package/src/workspace-client.js +36 -1
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the DeepSeek Harness (dsh) adapter.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is side-effect free and unit-tested (test/deepseek-runtime.test.js)
|
|
5
|
+
* so the adapter itself stays a thin subprocess driver. The split mirrors
|
|
6
|
+
* cline-stream.js / pi-stream.js.
|
|
7
|
+
*
|
|
8
|
+
* Verified against @deepseek-ai/dsh 0.1.0-rc.6 (bin `dsh` -> lib/bin.js, ESM).
|
|
9
|
+
*
|
|
10
|
+
* Three things here exist because dsh is unlike every other CLI in this repo:
|
|
11
|
+
*
|
|
12
|
+
* 1. It is a PREVIEW release (`0.1.0-rc.6`) and upstream says compatibility may
|
|
13
|
+
* break between previews, so the gate is exact equality — not a floor. The
|
|
14
|
+
* two version comparators already in this repo (installer.js's
|
|
15
|
+
* `compareVersions`, pi-stream.js's `parseVersion`) both DROP the `-rc.N`
|
|
16
|
+
* suffix, which would make rc.5 and rc.6 indistinguishable. Hence the
|
|
17
|
+
* prerelease-aware parser below.
|
|
18
|
+
*
|
|
19
|
+
* 2. `dsh --profile headless "<task>"` takes the task as a POSITIONAL ARGUMENT
|
|
20
|
+
* and has no stdin task channel. Putting the workspace prompt there would
|
|
21
|
+
* publish the workspace token to /proc/<pid>/cmdline and `ps`. The adapter
|
|
22
|
+
* therefore writes the prompt to a private task file and passes a constant
|
|
23
|
+
* sentence in argv — see HEADLESS_TASK_INSTRUCTION.
|
|
24
|
+
*
|
|
25
|
+
* 3. dsh's own composition mounts an approval row whose policy is `ask`
|
|
26
|
+
* everywhere except `danger-full-access`. A headless run has no client that
|
|
27
|
+
* could answer, so the adapter ships a private patch that sets `approval:
|
|
28
|
+
* never` while KEEPING `sandbox: workspace-write` — non-interactive
|
|
29
|
+
* execution without widening the filesystem boundary. Reaching the same
|
|
30
|
+
* place via DSH_PERMISSION_MODE=danger-full-access would also unsandbox the
|
|
31
|
+
* agent, which is why that value is never used to solve this.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
const crypto = require('crypto');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The dsh release this adapter is written against.
|
|
41
|
+
*
|
|
42
|
+
* FALLBACK ONLY. The authoritative version lives in the registry
|
|
43
|
+
* (`install.supported_version` in deepseek.yaml / registry.json) so the catalog,
|
|
44
|
+
* the installer gate and the runtime gate cannot drift; the adapter reads it
|
|
45
|
+
* from there and only falls back here when the registry entry cannot be read at
|
|
46
|
+
* all. Mirrors pi-stream.js's MIN_PI_VERSION rationale.
|
|
47
|
+
*/
|
|
48
|
+
const SUPPORTED_DSH_VERSION = '0.1.0-rc.6';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* dsh's Node requirement, from the upstream repository's root package.json:
|
|
52
|
+
*
|
|
53
|
+
* "engines": { "node": "^22.19.0 || >=24.0.0" }
|
|
54
|
+
*
|
|
55
|
+
* None of the PUBLISHED packages (@deepseek-ai/dsh, dsh-base, dsh-headless,
|
|
56
|
+
* cordis) carry an `engines` field, so npm will happily install dsh onto Node 20
|
|
57
|
+
* and it then fails at runtime with an unrelated-looking error. The gate has to
|
|
58
|
+
* live here.
|
|
59
|
+
*
|
|
60
|
+
* Note the caret: `^22.19.0` does NOT include 23.x. Pi's gate is effectively
|
|
61
|
+
* ">= 22.19.0" and would wrongly accept Node 23, so this cannot reuse it.
|
|
62
|
+
*/
|
|
63
|
+
const NODE_MIN_22 = [22, 19, 0];
|
|
64
|
+
|
|
65
|
+
/** Row ids in dsh's own composition that the private patch addresses. */
|
|
66
|
+
const ROW = {
|
|
67
|
+
AGENT_DEFAULT_MODEL: 'agent-default-model',
|
|
68
|
+
APPROVAL: 'approval',
|
|
69
|
+
PERMISSION: 'permission',
|
|
70
|
+
USER_QUESTIONS: 'user-questions',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Permission modes dsh's `permission-presets` row declares. */
|
|
74
|
+
const PERMISSION_MODES = ['read-only', 'workspace-write', 'danger-full-access'];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The ONLY thing that ever reaches argv as the headless task.
|
|
78
|
+
*
|
|
79
|
+
* The real prompt (workspace rules, bounded recap, the user's request) lives in
|
|
80
|
+
* the task file. Keeping this a constant is what makes "no user content and no
|
|
81
|
+
* secret is ever visible in the process list" an assertable property rather
|
|
82
|
+
* than a length-dependent hope.
|
|
83
|
+
*/
|
|
84
|
+
const HEADLESS_TASK_INSTRUCTION =
|
|
85
|
+
'Read the task file at %s and complete the task described in it. ' +
|
|
86
|
+
'Do not ask the user any questions.';
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Version parsing (prerelease-aware)
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract a semver-ish version, PRESERVING any prerelease suffix, from raw CLI
|
|
94
|
+
* output such as `dsh 0.1.0-rc.6` or a bare `0.1.0-rc.6`.
|
|
95
|
+
*
|
|
96
|
+
* Returns null when nothing version-shaped is present. Build metadata (`+sha`)
|
|
97
|
+
* is dropped: semver says it does not participate in precedence.
|
|
98
|
+
*/
|
|
99
|
+
function parseDshVersion(raw) {
|
|
100
|
+
if (raw == null) return null;
|
|
101
|
+
const m = String(raw).match(/(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
|
|
102
|
+
if (!m) return null;
|
|
103
|
+
const core = `${m[1]}.${m[2]}.${m[3]}`;
|
|
104
|
+
return m[4] ? `${core}-${m[4]}` : core;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Split a parsed version into { core: number[], pre: string[]|null }. */
|
|
108
|
+
function splitVersion(v) {
|
|
109
|
+
const parsed = parseDshVersion(v);
|
|
110
|
+
if (!parsed) return null;
|
|
111
|
+
const [core, pre] = parsed.split('-');
|
|
112
|
+
return {
|
|
113
|
+
core: core.split('.').map((n) => parseInt(n, 10)),
|
|
114
|
+
pre: pre ? pre.split('.') : null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Compare two prerelease identifiers per semver rules. */
|
|
119
|
+
function comparePreIdentifier(a, b) {
|
|
120
|
+
const na = /^\d+$/.test(a);
|
|
121
|
+
const nb = /^\d+$/.test(b);
|
|
122
|
+
if (na && nb) {
|
|
123
|
+
const d = parseInt(a, 10) - parseInt(b, 10);
|
|
124
|
+
return d === 0 ? 0 : d > 0 ? 1 : -1;
|
|
125
|
+
}
|
|
126
|
+
// Numeric identifiers always have lower precedence than alphanumeric ones.
|
|
127
|
+
if (na) return -1;
|
|
128
|
+
if (nb) return 1;
|
|
129
|
+
if (a === b) return 0;
|
|
130
|
+
return a > b ? 1 : -1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* -1 / 0 / 1 with FULL semver precedence, including prerelease ordering:
|
|
135
|
+
*
|
|
136
|
+
* 0.1.0-rc.5 < 0.1.0-rc.6 < 0.1.0
|
|
137
|
+
*
|
|
138
|
+
* Returns null when either side cannot be parsed — callers must treat that as
|
|
139
|
+
* "unknown" rather than silently passing a gate.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately NOT shared with installer.js's `compareVersions` or
|
|
142
|
+
* pi-stream.js's `parseVersion`: both of those match only `\d+(\.\d+)*` and so
|
|
143
|
+
* collapse every 0.1.0-rc.N to 0.1.0. Those two are load-bearing for other
|
|
144
|
+
* agents and are left alone; this is the dsh-specific replacement.
|
|
145
|
+
*/
|
|
146
|
+
function compareDshVersions(a, b) {
|
|
147
|
+
const pa = splitVersion(a);
|
|
148
|
+
const pb = splitVersion(b);
|
|
149
|
+
if (!pa || !pb) return null;
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < 3; i++) {
|
|
152
|
+
const x = pa.core[i] || 0;
|
|
153
|
+
const y = pb.core[i] || 0;
|
|
154
|
+
if (x !== y) return x > y ? 1 : -1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A version WITH a prerelease has lower precedence than one without.
|
|
158
|
+
if (!pa.pre && !pb.pre) return 0;
|
|
159
|
+
if (!pa.pre) return 1;
|
|
160
|
+
if (!pb.pre) return -1;
|
|
161
|
+
|
|
162
|
+
const len = Math.max(pa.pre.length, pb.pre.length);
|
|
163
|
+
for (let i = 0; i < len; i++) {
|
|
164
|
+
const x = pa.pre[i];
|
|
165
|
+
const y = pb.pre[i];
|
|
166
|
+
if (x === undefined) return -1; // shorter prerelease sorts first
|
|
167
|
+
if (y === undefined) return 1;
|
|
168
|
+
const c = comparePreIdentifier(x, y);
|
|
169
|
+
if (c !== 0) return c;
|
|
170
|
+
}
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Preview-era compatibility gate: the installed dsh must be EXACTLY the version
|
|
176
|
+
* this adapter was written against.
|
|
177
|
+
*
|
|
178
|
+
* Returns { compatible: true|false|null, detected, supported, message }.
|
|
179
|
+
* `compatible: null` means the version could not be read — reported as unknown,
|
|
180
|
+
* never as a pass.
|
|
181
|
+
*
|
|
182
|
+
* `installCommand` is echoed into the failure message because the recovery is
|
|
183
|
+
* not obvious: the Launcher's update path would otherwise move the user to a
|
|
184
|
+
* newer preview that this adapter refuses to run.
|
|
185
|
+
*/
|
|
186
|
+
function classifyDshVersion(rawVersion, supported, installCommand) {
|
|
187
|
+
const want = parseDshVersion(supported || SUPPORTED_DSH_VERSION);
|
|
188
|
+
const detected = parseDshVersion(rawVersion);
|
|
189
|
+
if (!detected) {
|
|
190
|
+
return {
|
|
191
|
+
compatible: null,
|
|
192
|
+
detected: null,
|
|
193
|
+
supported: want,
|
|
194
|
+
message:
|
|
195
|
+
`Could not read the DeepSeek Harness version (expected ${want}). ` +
|
|
196
|
+
'Reinstall with: ' + (installCommand || defaultInstallCommand(want)),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (compareDshVersions(detected, want) === 0) {
|
|
200
|
+
return { compatible: true, detected, supported: want, message: null };
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
compatible: false,
|
|
204
|
+
detected,
|
|
205
|
+
supported: want,
|
|
206
|
+
message:
|
|
207
|
+
`DeepSeek Harness ${detected} is not supported — this agent is built ` +
|
|
208
|
+
`against ${want} exactly. DeepSeek ships the harness as a developer ` +
|
|
209
|
+
'preview and its configuration can change between previews, so the ' +
|
|
210
|
+
'version is pinned. Reinstall with: ' +
|
|
211
|
+
(installCommand || defaultInstallCommand(want)),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The npm command that restores the pinned preview. */
|
|
216
|
+
function defaultInstallCommand(version) {
|
|
217
|
+
return `npm install -g @deepseek-ai/dsh@${version || SUPPORTED_DSH_VERSION}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Node version gate
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Classify a `node --version` string against dsh's `^22.19.0 || >=24.0.0`.
|
|
226
|
+
*
|
|
227
|
+
* Returns { version, supported }. `version` is null when unparseable, in which
|
|
228
|
+
* case `supported` is null ("unknown") rather than false.
|
|
229
|
+
*
|
|
230
|
+
* The 23.x rejection is the whole reason this is not shared with Pi: Pi's gate
|
|
231
|
+
* is a plain floor and would accept 23.x, which dsh's caret range excludes.
|
|
232
|
+
*/
|
|
233
|
+
function classifyNodeVersion(raw) {
|
|
234
|
+
const parsed = parseDshVersion(raw);
|
|
235
|
+
if (!parsed) return { version: null, supported: null };
|
|
236
|
+
const [major, minor, patch] = parsed.split('-')[0].split('.').map((n) => parseInt(n, 10));
|
|
237
|
+
|
|
238
|
+
if (major >= 24) return { version: parsed, supported: true };
|
|
239
|
+
if (major === 23) return { version: parsed, supported: false };
|
|
240
|
+
if (major === 22) {
|
|
241
|
+
const ok =
|
|
242
|
+
minor > NODE_MIN_22[1] ||
|
|
243
|
+
(minor === NODE_MIN_22[1] && patch >= NODE_MIN_22[2]);
|
|
244
|
+
return { version: parsed, supported: ok };
|
|
245
|
+
}
|
|
246
|
+
return { version: parsed, supported: false };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Human-readable requirement, used in the incompatibility message. */
|
|
250
|
+
function nodeRequirementText() {
|
|
251
|
+
return `Node ${NODE_MIN_22.join('.')}+ (22.x) or Node 24+; Node 23.x is not supported`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Private DSH_HOME naming
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A filesystem-safe, COLLISION-FREE directory name for one agent's private
|
|
260
|
+
* DSH_HOME.
|
|
261
|
+
*
|
|
262
|
+
* Slugging alone is not enough: `my/agent` and `my:agent` both slug to
|
|
263
|
+
* `my-agent`, and two agents sharing a DSH_HOME would share sessions, settings
|
|
264
|
+
* and credentials. The 8-hex digest of the raw pair keeps the name readable
|
|
265
|
+
* while making a collision require an actual SHA-256 prefix collision.
|
|
266
|
+
*/
|
|
267
|
+
function safeDshHomeName(workspaceId, agentName) {
|
|
268
|
+
const raw = `${workspaceId == null ? '' : workspaceId}\u0000${agentName == null ? '' : agentName}`;
|
|
269
|
+
const hash = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
|
|
270
|
+
const slug = (s) =>
|
|
271
|
+
String(s == null ? '' : s)
|
|
272
|
+
.toLowerCase()
|
|
273
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
274
|
+
.replace(/^-+|-+$/g, '')
|
|
275
|
+
.slice(0, 32) || 'agent';
|
|
276
|
+
return `${slug(workspaceId)}_${slug(agentName)}-${hash}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Entry-point resolution
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Candidate paths to dsh's real JS entry point, given the resolved `dsh` shim.
|
|
285
|
+
*
|
|
286
|
+
* Pure so every install layout can be asserted without a filesystem — the
|
|
287
|
+
* caller picks the first that exists. Order is by likelihood, not preference;
|
|
288
|
+
* only one of these can exist for a given install.
|
|
289
|
+
*
|
|
290
|
+
* <prefix>/node_modules/.bin/dsh(.cmd) → ../@deepseek-ai/dsh/lib/bin.js
|
|
291
|
+
* The Launcher's MANAGED runtime (~/.openagents/runtimes/deepseek). This
|
|
292
|
+
* one is load-bearing on Windows, where the shim is a `.cmd` with no
|
|
293
|
+
* symlink to follow, making this the ONLY route to the entry point.
|
|
294
|
+
* <prefix>/bin/dsh → ../lib/node_modules/… (unix global)
|
|
295
|
+
* <prefix>/dsh.cmd → ./node_modules/… (windows global)
|
|
296
|
+
*
|
|
297
|
+
* @param {string} binPath the resolved shim
|
|
298
|
+
* @param {object} [p] path implementation, so win32 layouts can be tested from
|
|
299
|
+
* any host platform
|
|
300
|
+
*/
|
|
301
|
+
function dshEntryCandidates(binPath, p = path) {
|
|
302
|
+
if (!binPath) return [];
|
|
303
|
+
const dir = p.dirname(binPath);
|
|
304
|
+
const rel = ['@deepseek-ai', 'dsh', 'lib', 'bin.js'];
|
|
305
|
+
return [
|
|
306
|
+
p.resolve(dir, '..', ...rel),
|
|
307
|
+
p.resolve(dir, '..', 'lib', 'node_modules', ...rel),
|
|
308
|
+
p.resolve(dir, '..', 'node_modules', ...rel),
|
|
309
|
+
p.resolve(dir, 'node_modules', ...rel),
|
|
310
|
+
];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// argv construction
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Build the argument vector that follows the Node binary.
|
|
319
|
+
*
|
|
320
|
+
* Order is load-bearing: dsh's launcher parses only its own flags and hands
|
|
321
|
+
* everything after the first token it does not recognise to the booted profile.
|
|
322
|
+
* Launcher flags therefore MUST precede the positional task.
|
|
323
|
+
*
|
|
324
|
+
* The task argument is a constant sentence naming the task file — never the
|
|
325
|
+
* prompt itself. See HEADLESS_TASK_INSTRUCTION.
|
|
326
|
+
*/
|
|
327
|
+
function buildHeadlessArgs({ jsEntry, taskFile, patchFile }) {
|
|
328
|
+
if (!jsEntry) throw new Error('buildHeadlessArgs: jsEntry is required');
|
|
329
|
+
if (!taskFile) throw new Error('buildHeadlessArgs: taskFile is required');
|
|
330
|
+
const args = [jsEntry, '--profile', 'headless'];
|
|
331
|
+
if (patchFile) args.push('--patch', patchFile);
|
|
332
|
+
args.push(HEADLESS_TASK_INSTRUCTION.replace('%s', taskFile));
|
|
333
|
+
return args;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The bootstrap probe: composes the profile without booting or calling a model. */
|
|
337
|
+
function buildDumpConfigArgs({ jsEntry, patchFile }) {
|
|
338
|
+
if (!jsEntry) throw new Error('buildDumpConfigArgs: jsEntry is required');
|
|
339
|
+
const args = [jsEntry, '--profile', 'headless'];
|
|
340
|
+
if (patchFile) args.push('--patch', patchFile);
|
|
341
|
+
args.push('--dump-config');
|
|
342
|
+
return args;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Private patch file
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Emit a YAML scalar safely.
|
|
351
|
+
*
|
|
352
|
+
* Always quotes and always escapes. The model id is user-supplied through the
|
|
353
|
+
* Launcher, and an unquoted `foo: bar` or a stray newline there would rewrite
|
|
354
|
+
* the patch's structure — a config-injection hole. This is stricter than
|
|
355
|
+
* config.js's serializeYamlValue (which quotes only when it detects a special
|
|
356
|
+
* character) precisely because the input is untrusted.
|
|
357
|
+
*/
|
|
358
|
+
function yamlScalar(value) {
|
|
359
|
+
const s = String(value == null ? '' : value);
|
|
360
|
+
const escaped = s
|
|
361
|
+
.replace(/\\/g, '\\\\')
|
|
362
|
+
.replace(/"/g, '\\"')
|
|
363
|
+
.replace(/\r/g, '\\r')
|
|
364
|
+
.replace(/\n/g, '\\n')
|
|
365
|
+
.replace(/\t/g, '\\t');
|
|
366
|
+
return `"${escaped}"`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The private `--patch` overlay this adapter ships with every dsh run.
|
|
371
|
+
*
|
|
372
|
+
* It carries three things, all of which are configuration dsh has no
|
|
373
|
+
* environment variable for:
|
|
374
|
+
*
|
|
375
|
+
* 1. `user-questions` disabled — a headless run has no surface on which the
|
|
376
|
+
* agent could ask, so leaving it mounted only creates a way to hang.
|
|
377
|
+
* 2. `approval` forced to `never`, and the presets rewritten to match, WITHOUT
|
|
378
|
+
* touching `sandbox`. dsh's own preset table pairs `approval: never` only
|
|
379
|
+
* with `sandbox: danger-full-access`; keeping `workspace-write` alongside
|
|
380
|
+
* `never` is what gives non-interactive execution that is still confined to
|
|
381
|
+
* the workspace for writes. (Reads are not confined in any mode.)
|
|
382
|
+
* 3. The model/provider override, since `agent-default-model` is a composition
|
|
383
|
+
* row rather than an environment variable.
|
|
384
|
+
*
|
|
385
|
+
* A patch REPLACES the targeted row's whole `config`, so each row below is
|
|
386
|
+
* written out complete rather than as a partial merge.
|
|
387
|
+
*/
|
|
388
|
+
function buildPrivatePatch({ model, provider, permissionMode } = {}) {
|
|
389
|
+
const mode = normalizePermissionMode(permissionMode) || 'workspace-write';
|
|
390
|
+
const sandboxFor = (m) => (m === 'read-only' ? 'read-only' : m === 'danger-full-access' ? 'danger-full-access' : 'workspace-write');
|
|
391
|
+
|
|
392
|
+
const lines = [
|
|
393
|
+
'# Generated by OpenAgents for the DeepSeek Harness adapter.',
|
|
394
|
+
`# Pinned to dsh ${SUPPORTED_DSH_VERSION}. Do not edit by hand: this file is`,
|
|
395
|
+
'# rewritten on every run and lives inside a per-agent private DSH_HOME.',
|
|
396
|
+
'',
|
|
397
|
+
`- id: ${ROW.USER_QUESTIONS}`,
|
|
398
|
+
' disabled: true',
|
|
399
|
+
'',
|
|
400
|
+
`- id: ${ROW.APPROVAL}`,
|
|
401
|
+
' config:',
|
|
402
|
+
' policy: never',
|
|
403
|
+
'',
|
|
404
|
+
`- id: ${ROW.PERMISSION}`,
|
|
405
|
+
' config:',
|
|
406
|
+
' presets:',
|
|
407
|
+
];
|
|
408
|
+
|
|
409
|
+
for (const m of PERMISSION_MODES) {
|
|
410
|
+
lines.push(` ${m}:`);
|
|
411
|
+
lines.push(` sandbox: ${sandboxFor(m)}`);
|
|
412
|
+
lines.push(' approval: never');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (model || provider) {
|
|
416
|
+
lines.push('');
|
|
417
|
+
lines.push(`- id: ${ROW.AGENT_DEFAULT_MODEL}`);
|
|
418
|
+
lines.push(' config:');
|
|
419
|
+
lines.push(` provider: ${yamlScalar(provider || 'deepseek-official')}`);
|
|
420
|
+
if (model) lines.push(` model: ${yamlScalar(model)}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
lines.push('');
|
|
424
|
+
return { text: lines.join('\n'), mode };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Permission mode
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Validate DSH_PERMISSION_MODE strictly. Returns the mode, or null when the
|
|
433
|
+
* value is not one dsh declares.
|
|
434
|
+
*
|
|
435
|
+
* A silent fallback is the wrong behaviour here: a typo'd `workspace_write`
|
|
436
|
+
* would quietly run under whatever dsh defaults to, which is the opposite of
|
|
437
|
+
* what the user asked for on a permission setting.
|
|
438
|
+
*/
|
|
439
|
+
function normalizePermissionMode(value) {
|
|
440
|
+
if (value == null || value === '') return null;
|
|
441
|
+
const v = String(value).trim();
|
|
442
|
+
return PERMISSION_MODES.includes(v) ? v : null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** The mode to run under, given the workspace mode and the user's setting. */
|
|
446
|
+
function resolvePermissionMode({ workspaceMode, configured }) {
|
|
447
|
+
// Plan mode is read-only regardless of what the agent is configured with:
|
|
448
|
+
// OpenAgents promises plan mode makes no changes.
|
|
449
|
+
if (workspaceMode === 'plan') return 'read-only';
|
|
450
|
+
return normalizePermissionMode(configured) || 'workspace-write';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// Output handling
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Hard cap on buffered stdout. dsh emits nothing until the run ends and then
|
|
459
|
+
* writes one assistant message, so this is not a streaming budget — it is the
|
|
460
|
+
* "a wedged process must not OOM the daemon" backstop. Mirrors pi-stream.js's
|
|
461
|
+
* MAX_LINE_BYTES rationale.
|
|
462
|
+
*/
|
|
463
|
+
const MAX_STDOUT_BYTES = 16 * 1024 * 1024;
|
|
464
|
+
|
|
465
|
+
/** Cap on the reply actually posted to the workspace. */
|
|
466
|
+
const MAX_REPLY_CHARS = 64 * 1024;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Cap on retained stderr, as a rolling TAIL rather than a prefix.
|
|
470
|
+
*
|
|
471
|
+
* dsh writes a terminal error's code and message at the END of the stream, so
|
|
472
|
+
* keeping the head would discard the only line that explains the failure. The
|
|
473
|
+
* byte form bounds what the adapter buffers while a run is in flight; the char
|
|
474
|
+
* form bounds what is finally reported.
|
|
475
|
+
\*/
|
|
476
|
+
const MAX_STDERR_BYTES = 8 * 1024;
|
|
477
|
+
const MAX_STDERR_CHARS = 8 * 1024;
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Turn raw stdout into the reply to post.
|
|
481
|
+
*
|
|
482
|
+
* dsh writes the last non-empty assistant text and nothing else on success, so
|
|
483
|
+
* this is deliberately minimal: trim, then bound. Returns { text, truncated }.
|
|
484
|
+
*/
|
|
485
|
+
function cleanStdout(raw, { maxChars = MAX_REPLY_CHARS } = {}) {
|
|
486
|
+
const s = String(raw == null ? '' : raw).replace(/\s+$/, '');
|
|
487
|
+
if (s.length <= maxChars) return { text: s, truncated: false };
|
|
488
|
+
return {
|
|
489
|
+
text: s.slice(0, maxChars) + '\n\n[output truncated by OpenAgents]',
|
|
490
|
+
truncated: true,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Keep the TAIL of stderr, not the head.
|
|
496
|
+
*
|
|
497
|
+
* dsh writes a terminal `error` reason's code and message at the end of the
|
|
498
|
+
* stream; truncating from the front would throw away the only line that
|
|
499
|
+
* explains the failure.
|
|
500
|
+
*/
|
|
501
|
+
function tailStderr(raw, { maxChars = MAX_STDERR_CHARS } = {}) {
|
|
502
|
+
const s = String(raw == null ? '' : raw).trim();
|
|
503
|
+
return s.length <= maxChars ? s : '…' + s.slice(-maxChars);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Failure categories surfaced to the workspace. */
|
|
507
|
+
const FAILURE = {
|
|
508
|
+
AUTH: 'auth',
|
|
509
|
+
MODEL: 'model',
|
|
510
|
+
NETWORK: 'network',
|
|
511
|
+
CONFIG: 'config',
|
|
512
|
+
PERMISSION: 'permission',
|
|
513
|
+
VERSION: 'version',
|
|
514
|
+
UNKNOWN: 'unknown',
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const FAILURE_PATTERNS = [
|
|
518
|
+
[FAILURE.AUTH, /\b(401|403|unauthor|forbidden|invalid[_\s-]?api[_\s-]?key|authentication|no api key|missing api key|credential)/i],
|
|
519
|
+
[FAILURE.MODEL, /\b(model[_\s-]?not[_\s-]?found|unknown model|unsupported model|no such model|context[_\s-]?length|token limit|quota|rate[_\s-]?limit|429)/i],
|
|
520
|
+
[FAILURE.NETWORK, /\b(econnrefused|enotfound|etimedout|econnreset|socket hang up|network|dns|proxy|certificate|tls|ssl|502|503|504)/i],
|
|
521
|
+
[FAILURE.PERMISSION, /\b(eacces|eperm|erofs|permission denied|read[_\s-]?only file system|operation not permitted)/i],
|
|
522
|
+
[FAILURE.VERSION, /\b(unsupported node|engine|requires node|syntaxerror.*unexpected token|err_require_esm|cannot use import statement)/i],
|
|
523
|
+
[FAILURE.CONFIG, /\b(profile|cordis|patch|settings\.yaml|plugin|bundle|dsh_home|composition|row)\b/i],
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Classify a non-zero dsh run.
|
|
528
|
+
*
|
|
529
|
+
* The `stdout` of a failed run is deliberately IGNORED by the caller: dsh only
|
|
530
|
+
* guarantees the final assistant text on success, so whatever partial text is
|
|
531
|
+
* sitting in the buffer of a failed run is not an answer.
|
|
532
|
+
*
|
|
533
|
+
* Returns { category, message } with the message already tail-bounded. The
|
|
534
|
+
* caller is responsible for the final redaction pass.
|
|
535
|
+
*/
|
|
536
|
+
function classifyDshFailure({ code, signal, stderr } = {}) {
|
|
537
|
+
const detail = tailStderr(stderr);
|
|
538
|
+
if (signal) {
|
|
539
|
+
return {
|
|
540
|
+
category: FAILURE.UNKNOWN,
|
|
541
|
+
message: `DeepSeek Harness was terminated by signal ${signal}.` + (detail ? `\n\n${detail}` : ''),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
for (const [category, re] of FAILURE_PATTERNS) {
|
|
545
|
+
if (re.test(detail)) return { category, message: detail };
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
category: FAILURE.UNKNOWN,
|
|
549
|
+
message:
|
|
550
|
+
detail ||
|
|
551
|
+
`DeepSeek Harness exited with code ${code == null ? '?' : code} and wrote no diagnostics.`,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
// Session GC
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
/** Keep at most this many persisted sessions per agent. */
|
|
560
|
+
const SESSION_KEEP_COUNT = 50;
|
|
561
|
+
|
|
562
|
+
/** Drop persisted sessions older than this. */
|
|
563
|
+
const SESSION_KEEP_MS = 7 * 24 * 60 * 60 * 1000;
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Decide which persisted sessions to delete.
|
|
567
|
+
*
|
|
568
|
+
* Every headless run creates one fresh persisted session (dsh has no resume),
|
|
569
|
+
* so the sessions directory grows once per workspace message and would never
|
|
570
|
+
* shrink on its own.
|
|
571
|
+
*
|
|
572
|
+
* The rule is OR: an entry goes if it is older than SESSION_KEEP_MS **or** it
|
|
573
|
+
* falls outside the newest SESSION_KEEP_COUNT. Both bounds matter — a busy
|
|
574
|
+
* agent can blow past the count inside a day, and an idle one accumulates stale
|
|
575
|
+
* sessions that the count alone would never reach.
|
|
576
|
+
*
|
|
577
|
+
* `entries` is [{ name, mtimeMs }]. Callers must have already excluded anything
|
|
578
|
+
* that is not a regular file/directory (symlinks are rejected upstream) and
|
|
579
|
+
* must only call this when the adapter has no live dsh child: a session
|
|
580
|
+
* belonging to a run still in flight would otherwise look like a stale one.
|
|
581
|
+
*/
|
|
582
|
+
function selectSessionsForGc(entries, { now, keepCount = SESSION_KEEP_COUNT, keepMs = SESSION_KEEP_MS } = {}) {
|
|
583
|
+
const list = (entries || []).filter((e) => e && e.name);
|
|
584
|
+
if (list.length === 0) return [];
|
|
585
|
+
const cutoff = (typeof now === 'number' ? now : Date.now()) - keepMs;
|
|
586
|
+
|
|
587
|
+
const byNewest = [...list].sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
|
|
588
|
+
const keep = new Set(byNewest.slice(0, keepCount).map((e) => e.name));
|
|
589
|
+
|
|
590
|
+
return list
|
|
591
|
+
.filter((e) => !keep.has(e.name) || (e.mtimeMs || 0) < cutoff)
|
|
592
|
+
.map((e) => e.name);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
module.exports = {
|
|
596
|
+
SUPPORTED_DSH_VERSION,
|
|
597
|
+
NODE_MIN_22,
|
|
598
|
+
ROW,
|
|
599
|
+
PERMISSION_MODES,
|
|
600
|
+
HEADLESS_TASK_INSTRUCTION,
|
|
601
|
+
|
|
602
|
+
parseDshVersion,
|
|
603
|
+
compareDshVersions,
|
|
604
|
+
classifyDshVersion,
|
|
605
|
+
defaultInstallCommand,
|
|
606
|
+
|
|
607
|
+
classifyNodeVersion,
|
|
608
|
+
nodeRequirementText,
|
|
609
|
+
|
|
610
|
+
safeDshHomeName,
|
|
611
|
+
|
|
612
|
+
dshEntryCandidates,
|
|
613
|
+
buildHeadlessArgs,
|
|
614
|
+
buildDumpConfigArgs,
|
|
615
|
+
|
|
616
|
+
yamlScalar,
|
|
617
|
+
buildPrivatePatch,
|
|
618
|
+
|
|
619
|
+
normalizePermissionMode,
|
|
620
|
+
resolvePermissionMode,
|
|
621
|
+
|
|
622
|
+
MAX_STDOUT_BYTES,
|
|
623
|
+
MAX_REPLY_CHARS,
|
|
624
|
+
MAX_STDERR_BYTES,
|
|
625
|
+
MAX_STDERR_CHARS,
|
|
626
|
+
FAILURE,
|
|
627
|
+
cleanStdout,
|
|
628
|
+
tailStderr,
|
|
629
|
+
classifyDshFailure,
|
|
630
|
+
|
|
631
|
+
SESSION_KEEP_COUNT,
|
|
632
|
+
SESSION_KEEP_MS,
|
|
633
|
+
selectSessionsForGc,
|
|
634
|
+
};
|