@openagents-org/agent-launcher 0.2.168 → 0.2.170

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.
@@ -21,6 +21,7 @@ const ClineAdapter = require('./cline');
21
21
  const AmpAdapter = require('./amp');
22
22
  const MiniSweAgentAdapter = require('./mini');
23
23
  const PiAdapter = require('./pi');
24
+ const DeepSeekAdapter = require('./deepseek');
24
25
 
25
26
  const ADAPTER_MAP = {
26
27
  openclaw: OpenClawAdapter,
@@ -39,11 +40,12 @@ const ADAPTER_MAP = {
39
40
  amp: AmpAdapter,
40
41
  'mini-swe-agent': MiniSweAgentAdapter,
41
42
  pi: PiAdapter,
43
+ deepseek: DeepSeekAdapter,
42
44
  };
43
45
 
44
46
  /**
45
47
  * Create an adapter instance for the given agent type.
46
- * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp, mini-swe-agent, pi)
48
+ * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp, mini-swe-agent, pi, deepseek)
47
49
  * @param {object} opts - Adapter constructor options
48
50
  * @returns {BaseAdapter}
49
51
  */
@@ -73,6 +75,7 @@ module.exports = {
73
75
  AmpAdapter,
74
76
  MiniSweAgentAdapter,
75
77
  PiAdapter,
78
+ DeepSeekAdapter,
76
79
  createAdapter,
77
80
  ADAPTER_MAP,
78
81
  };
@@ -52,11 +52,30 @@ function generateSessionTitle(message, maxWords = 6) {
52
52
 
53
53
  /**
54
54
  * Format attachment metadata into text to append to an agent prompt.
55
+ *
56
+ * `tokenExpr` is the SHELL EXPRESSION the agent should use for the workspace
57
+ * token, not the token itself. It defaults to `$TOKEN` for the adapters that
58
+ * already export the token under that name. An adapter whose prompt must never
59
+ * contain the literal token (deepseek writes its prompt to a file and exports
60
+ * OPENAGENTS_WORKSPACE_TOKEN into the child environment) passes its own
61
+ * expression instead — otherwise `$TOKEN` expands to the empty string in the
62
+ * child and every attachment download silently 401s.
63
+ *
55
64
  * @param {Array} attachments
56
65
  * @param {'mcp'|'skills'} [toolMode='mcp']
57
66
  * @param {boolean} [isWindows]
67
+ * @param {object} [opts]
68
+ * @param {string} [opts.tokenExpr='$TOKEN'] shell expression yielding the token
69
+ * @param {string} [opts.endpoint] base URL used when an attachment carries no
70
+ * absolute `url`. Without it the fallback emits a literal `{WORKSPACE_API}`
71
+ * placeholder that nothing substitutes.
58
72
  */
59
- function formatAttachmentsForPrompt(attachments, toolMode = 'mcp', isWindows = process.platform === 'win32') {
73
+ function formatAttachmentsForPrompt(
74
+ attachments,
75
+ toolMode = 'mcp',
76
+ isWindows = process.platform === 'win32',
77
+ { tokenExpr = '$TOKEN', endpoint = null } = {},
78
+ ) {
60
79
  if (!attachments || attachments.length === 0) return null;
61
80
 
62
81
  const lines = ['\n[Attached files]'];
@@ -65,21 +84,22 @@ function formatAttachmentsForPrompt(attachments, toolMode = 'mcp', isWindows = p
65
84
  const fileId = att.fileId || '';
66
85
  const contentType = att.contentType || '';
67
86
  if (toolMode === 'skills') {
68
- const url = att.url || `{WORKSPACE_API}/v1/files/${fileId}`;
87
+ const base = endpoint ? String(endpoint).replace(/\/+$/, '') : '{WORKSPACE_API}';
88
+ const url = att.url || `${base}/v1/files/${fileId}`;
69
89
  const curl = isWindows ? 'curl.exe' : 'curl';
70
90
  const tmpDir = isWindows ? '$env:TEMP' : '/tmp';
71
91
  if (contentType.startsWith('image/')) {
72
92
  lines.push(
73
93
  `- Image: ${filename} (file_id: ${fileId}) — ` +
74
94
  `download with curl, then use your Read tool on the local file to view it:\n` +
75
- ` Step 1: ${curl} -s -H "X-Workspace-Token: $TOKEN" "${url}" -o ${tmpDir}/${filename}\n` +
95
+ ` Step 1: ${curl} -s -H "X-Workspace-Token: ${tokenExpr}" "${url}" -o ${tmpDir}/${filename}\n` +
76
96
  ` Step 2: Use the Read tool on ${tmpDir}/${filename} to see the image`
77
97
  );
78
98
  } else {
79
99
  lines.push(
80
100
  `- File: ${filename} (file_id: ${fileId}, type: ${contentType}) — ` +
81
101
  `download with curl, then use your Read tool on the local file:\n` +
82
- ` Step 1: ${curl} -s -H "X-Workspace-Token: $TOKEN" "${url}" -o ${tmpDir}/${filename}\n` +
102
+ ` Step 1: ${curl} -s -H "X-Workspace-Token: ${tokenExpr}" "${url}" -o ${tmpDir}/${filename}\n` +
83
103
  ` Step 2: Use the Read tool on ${tmpDir}/${filename} to read the file`
84
104
  );
85
105
  }
@@ -1021,6 +1021,68 @@ function buildPiSystemPrompt({ agentName, workspaceId, channelName, endpoint, to
1021
1021
  return identity + directive + '\n' + collab + '\n' + modePrompt + '\n' + api + '\n' + buildGuardrails();
1022
1022
  }
1023
1023
 
1024
+ /**
1025
+ * Build the task FILE contents for the DeepSeek Harness adapter.
1026
+ *
1027
+ * dsh takes its task as a positional argv element and has no stdin channel, so
1028
+ * the adapter writes everything here to a private file and passes only a
1029
+ * constant sentence on the command line. Two consequences shape this function:
1030
+ *
1031
+ * - The workspace token must NEVER appear. `tokenExpr` is a SHELL EXPRESSION
1032
+ * (`$OPENAGENTS_WORKSPACE_TOKEN`, or `$env:OPENAGENTS_WORKSPACE_TOKEN` on
1033
+ * Windows) that the agent expands at run time from the child environment.
1034
+ * Passing the real token here would put it on disk for no benefit — and the
1035
+ * guard below makes that a loud failure rather than a silent leak.
1036
+ *
1037
+ * - dsh has no session resume: each run is a fresh session. The channel recap
1038
+ * is therefore part of the task itself rather than something the agent can
1039
+ * be expected to remember.
1040
+ */
1041
+ function buildDeepSeekTaskFile({
1042
+ agentName, workspaceId, channelName, endpoint, tokenExpr,
1043
+ mode = 'execute', disabledModules, browserEnabled = false,
1044
+ recap = '', request = '', attachments = '',
1045
+ }) {
1046
+ // A token expression is a shell reference, never a credential. Anything that
1047
+ // does not start with `$` is almost certainly a real token passed by mistake.
1048
+ if (!tokenExpr || !/^\$/.test(String(tokenExpr))) {
1049
+ throw new Error(
1050
+ 'buildDeepSeekTaskFile: tokenExpr must be a shell expression such as '
1051
+ + '$OPENAGENTS_WORKSPACE_TOKEN — never the workspace token itself.',
1052
+ );
1053
+ }
1054
+
1055
+ const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills');
1056
+ const directive = buildBrowserDirective(browserEnabled);
1057
+ const collab = buildCollaborationPrompt('skills', workspaceSkillName(agentName));
1058
+ const modePrompt = buildModePrompt(mode);
1059
+ const api = buildApiSkillsPrompt({
1060
+ endpoint, workspaceId, token: tokenExpr, agentName, channelName, disabledModules, mode,
1061
+ });
1062
+
1063
+ const parts = [identity, directive, '\n', collab, '\n', modePrompt, '\n', api, '\n', buildGuardrails()];
1064
+
1065
+ if (recap) {
1066
+ parts.push(
1067
+ '\n## Recent channel history\n'
1068
+ + 'You are running as a fresh process with no memory of earlier turns. '
1069
+ + 'This is the relevant history — treat it as context, not as new work.\n\n'
1070
+ + recap + '\n',
1071
+ );
1072
+ }
1073
+
1074
+ parts.push(
1075
+ '\n## Your task\n'
1076
+ + 'Respond to the request below. Everything above is context and standing '
1077
+ + 'instruction; only this section is what you were asked to do now.\n\n'
1078
+ + String(request || '').trim() + '\n',
1079
+ );
1080
+
1081
+ if (attachments) parts.push('\n' + attachments + '\n');
1082
+
1083
+ return parts.join('');
1084
+ }
1085
+
1024
1086
  /**
1025
1087
  * Build workspace skill markdown for OpenCode (written to .opencode/skills/).
1026
1088
  */
@@ -1127,6 +1189,7 @@ module.exports = {
1127
1189
  buildOpenCodeSystemPrompt,
1128
1190
  buildOpenCodeSkillMd,
1129
1191
  buildPiSystemPrompt,
1192
+ buildDeepSeekTaskFile,
1130
1193
  buildClaudeSkillMd,
1131
1194
  buildCursorSkillMd,
1132
1195
  };
package/src/daemon.js CHANGED
@@ -7,6 +7,18 @@ const os = require('os');
7
7
  const { WorkspaceClient } = require('./workspace-client');
8
8
  const { getEnhancedEnv, whichBinary, IS_WINDOWS, defaultAgentWorkdir } = require('./paths');
9
9
 
10
+ /**
11
+ * Mask an API key for display (same shape the workspace backend uses for
12
+ * cloud agents): enough to recognize which key it is, never enough to use it.
13
+ * The partial reveal needs a real remainder to hide: first4+last4 of a
14
+ * 9-to-12-char key would expose most of it, so anything that short is
15
+ * fully masked.
16
+ */
17
+ function maskApiKey(key) {
18
+ if (!key || key.length <= 12) return '****';
19
+ return key.slice(0, 4) + '...' + key.slice(-4);
20
+ }
21
+
10
22
  /**
11
23
  * Agent process lifecycle manager.
12
24
  *
@@ -131,18 +143,21 @@ class Daemon {
131
143
  for (const a of this.config.getAgents()) {
132
144
  if (!this._agentOnNodeWorkspace(a, node)) continue;
133
145
  const proc = this._processes[a.name];
134
- // Model: per-agent env override, else the type-level saved model. Working
135
- // dir: the agent's configured path. Both power the workspace agent cards.
136
- let model = (a.env && a.env.LLM_MODEL) || null;
137
- if (!model) {
138
- try { model = (this.envManager.load(a.type) || {}).LLM_MODEL || null; } catch { model = null; }
139
- }
146
+ // Model/key: per-agent env override, else the type-level saved env.
147
+ // Both power the workspace agent cards; the key goes out MASKED only,
148
+ // so the edit form can show a key is configured without the secret
149
+ // ever leaving the device.
150
+ let typeEnv = {};
151
+ try { typeEnv = this.envManager.load(a.type) || {}; } catch {}
152
+ const model = (a.env && a.env.LLM_MODEL) || typeEnv.LLM_MODEL || null;
153
+ const apiKey = (a.env && a.env.LLM_API_KEY) || typeEnv.LLM_API_KEY || null;
140
154
  roster.push({
141
155
  name: a.name,
142
156
  type: a.type || 'unknown',
143
157
  status: (proc && proc.state) || 'stopped',
144
158
  model: model || null,
145
159
  workingDir: a.path || null,
160
+ apiKeyMasked: apiKey ? maskApiKey(apiKey) : null,
146
161
  });
147
162
  }
148
163
  } catch {
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Pre-flight checks for install commands that shell out to a third-party script.
3
+ *
4
+ * `curl … | bash` installers are black boxes: whatever they decide to do about a
5
+ * missing dependency happens with no way for us to explain it, cancel it, or
6
+ * even show a useful progress line. Hermes is the case that forced this file to
7
+ * exist. Its install.sh needs git to clone the repo, and on a mac with no
8
+ * developer tools it:
9
+ * 1. runs `git --version`, which on macOS is a SHIM that opens the system
10
+ * "The 'git' command requires the command line developer tools" dialog —
11
+ * a dialog with no OpenAgents branding, appearing seconds after the user
12
+ * pressed Install in our marketplace;
13
+ * 2. calls `xcode-select --install`, opening it a second time;
14
+ * 3. sleeps in a 5-second poll loop for up to 900 SECONDS waiting for git to
15
+ * appear, printing one line per minute.
16
+ * In the launcher that reads as a progress bar frozen on "downloading" for a
17
+ * quarter of an hour, so users retry, which starts the whole thing again.
18
+ *
19
+ * Catching the missing dependency BEFORE spawning the installer turns all of
20
+ * that into one actionable message.
21
+ *
22
+ * Only dependencies with a probe below are enforced. `install.requires` is
23
+ * hand-maintained and partly aspirational — hermes lists python3 even though
24
+ * its installer provisions Python through uv when none is found — so an unknown
25
+ * entry is left to the installer rather than blocking an install that would
26
+ * have worked.
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const { execFileSync } = require('child_process');
34
+
35
+ const IS_MACOS = process.platform === 'darwin';
36
+
37
+ /** Directories on PATH, minus any the caller wants skipped. */
38
+ function pathDirs(exclude = []) {
39
+ const raw = process.env.PATH || '';
40
+ return raw
41
+ .split(path.delimiter)
42
+ .map((d) => d.trim())
43
+ .filter((d) => d && !exclude.includes(d));
44
+ }
45
+
46
+ function existsIn(dirs, name, exists, isWindows) {
47
+ const names = isWindows ? [`${name}.exe`, `${name}.cmd`, name] : [name];
48
+ for (const dir of dirs) {
49
+ for (const n of names) {
50
+ const full = path.join(dir, n);
51
+ try {
52
+ if (exists(full)) return full;
53
+ } catch {
54
+ /* unreadable dir on PATH — skip */
55
+ }
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * The active developer directory, or null when none is selected. `xcode-select
63
+ * -p` is safe to run with no tools installed: it exits 2 and prints to stderr
64
+ * WITHOUT opening the install dialog. Running `git` is what opens it.
65
+ */
66
+ function activeDeveloperDir() {
67
+ try {
68
+ return (
69
+ execFileSync('xcode-select', ['-p'], {
70
+ encoding: 'utf-8',
71
+ timeout: 5000,
72
+ stdio: ['ignore', 'pipe', 'ignore'],
73
+ }).trim() || null
74
+ );
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Is a REAL git available?
82
+ *
83
+ * The hard constraint on macOS: never execute `git`, and never execute
84
+ * `/usr/bin/git` in particular. That file exists on every mac whether or not
85
+ * the Command Line Tools are installed, and running it is precisely what opens
86
+ * the install dialog we are trying to get ahead of. So this probe only ever
87
+ * touches the filesystem plus `xcode-select -p`, which reports the active
88
+ * developer directory (exit code 2, no dialog, when there is none).
89
+ *
90
+ * Two platforms are deliberately exempt, because on them a missing git is
91
+ * something the installer genuinely recovers from:
92
+ *
93
+ * - **Windows.** install.ps1's Install-Git downloads PortableGit from
94
+ * github.com into %LOCALAPPDATA%\hermes\git — no admin rights, no dialog, no
95
+ * winget. It is slow (the script's own comment budgets five minutes) and it
96
+ * can fail on a restricted network, but blocking the install would break the
97
+ * many machines where it works. What Windows needs from us is a readable
98
+ * error when that download fails, not a refusal — see userFacingInstallError
99
+ * in the launcher.
100
+ * - **Linux as root.** install.sh shells out to apt/dnf/pacman, which succeeds
101
+ * without a password prompt when we are already root. As a normal user the
102
+ * sudo call has no tty to ask on and fails, so the refusal stands there.
103
+ *
104
+ * @param {object} [deps] - seams for tests; production passes nothing.
105
+ */
106
+ function probeGit(deps = {}) {
107
+ const platform = deps.platform || process.platform;
108
+ const exists = deps.exists || ((p) => fs.existsSync(p));
109
+ const dirs = deps.dirs || null;
110
+
111
+ if (platform === 'win32') {
112
+ return { ok: true, detail: 'installer provisions PortableGit itself' };
113
+ }
114
+
115
+ if (platform !== 'darwin') {
116
+ const search = dirs || [...pathDirs(), '/usr/bin', '/usr/local/bin'];
117
+ const found = existsIn(search, 'git', exists, false);
118
+ if (found) return { ok: true, detail: found };
119
+ const uid = deps.uid !== undefined ? deps.uid : (process.getuid ? process.getuid() : null);
120
+ if (uid === 0) return { ok: true, detail: 'root can install git unattended' };
121
+ return { ok: false };
122
+ }
123
+
124
+ // A git outside /usr/bin (Homebrew, MacPorts, a manual install) is a real
125
+ // binary and safe to trust without running it.
126
+ const search = dirs || [
127
+ ...pathDirs(['/usr/bin']),
128
+ '/opt/homebrew/bin',
129
+ '/usr/local/bin',
130
+ ];
131
+ const found = existsIn(search, 'git', exists, false);
132
+ if (found) return { ok: true, detail: found };
133
+
134
+ // Otherwise the only candidate left is the /usr/bin shim, which forwards to
135
+ // the active developer directory. Resolve that directory and look for the git
136
+ // it would forward to: present means the shim works, absent means running it
137
+ // would open the dialog — which is exactly the case we refuse to walk into.
138
+ const developerDir =
139
+ 'developerDir' in deps ? deps.developerDir : activeDeveloperDir();
140
+
141
+ const candidates = [];
142
+ if (developerDir) candidates.push(path.join(developerDir, 'usr', 'bin', 'git'));
143
+ candidates.push('/Library/Developer/CommandLineTools/usr/bin/git');
144
+ for (const c of candidates) {
145
+ try {
146
+ if (exists(c)) return { ok: true, detail: c };
147
+ } catch {
148
+ /* ignore */
149
+ }
150
+ }
151
+
152
+ return { ok: false };
153
+ }
154
+
155
+ /**
156
+ * How the user fixes each missing dependency, phrased for the install screen.
157
+ * `action` is a hint the launcher UI can turn into a button; everything else
158
+ * degrades to plain text.
159
+ */
160
+ function gitRemedy() {
161
+ if (IS_MACOS) {
162
+ return {
163
+ name: 'git',
164
+ action: 'install-xcode-clt',
165
+ summary: 'Git is required, and it comes with the Xcode Command Line Tools.',
166
+ command: 'xcode-select --install',
167
+ alternative: 'brew install git',
168
+ };
169
+ }
170
+ return {
171
+ name: 'git',
172
+ action: null,
173
+ summary: 'Git is required to download this agent.',
174
+ command: 'sudo apt install git # or dnf / pacman, per your distro',
175
+ alternative: null,
176
+ };
177
+ }
178
+
179
+ const PROBES = {
180
+ git: { probe: probeGit, remedy: gitRemedy },
181
+ };
182
+
183
+ /**
184
+ * Check a registry entry's `install.requires` against the machine.
185
+ * @returns {{ ok: boolean, missing: Array<{name: string, action: string|null, summary: string, command: string, alternative: string|null}> }}
186
+ */
187
+ function checkInstallPrereqs(entry) {
188
+ const requires = entry && entry.install ? entry.install.requires : null;
189
+ if (!Array.isArray(requires) || requires.length === 0) return { ok: true, missing: [] };
190
+
191
+ const missing = [];
192
+ for (const name of requires) {
193
+ const spec = PROBES[String(name || '').toLowerCase()];
194
+ if (!spec) continue; // no probe → the installer's problem, not ours
195
+ let result;
196
+ try {
197
+ result = spec.probe();
198
+ } catch {
199
+ continue; // a probe that throws must never block an install
200
+ }
201
+ if (!result || !result.ok) missing.push(spec.remedy());
202
+ }
203
+ return { ok: missing.length === 0, missing };
204
+ }
205
+
206
+ /**
207
+ * The error thrown in place of running the installer. Carries `code` and
208
+ * `missing` so the launcher can render buttons instead of parsing prose, and a
209
+ * message that stands on its own for the CLI and the log.
210
+ */
211
+ function missingPrereqError(agentLabel, missing) {
212
+ const lines = [
213
+ `${agentLabel} cannot be installed yet — a required tool is missing.`,
214
+ '',
215
+ ];
216
+ for (const item of missing) {
217
+ lines.push(`• ${item.summary}`);
218
+ lines.push(` Install it with: ${item.command}`);
219
+ if (item.alternative) lines.push(` Or: ${item.alternative}`);
220
+ }
221
+ lines.push('', 'Once it is installed, come back and press Install again.');
222
+
223
+ const err = new Error(lines.join('\n'));
224
+ err.code = 'MISSING_PREREQ';
225
+ err.missing = missing;
226
+ return err;
227
+ }
228
+
229
+ module.exports = {
230
+ checkInstallPrereqs,
231
+ missingPrereqError,
232
+ probeGit,
233
+ };