@semalt-ai/code 1.8.5 → 1.20.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/.claude/settings.local.json +7 -1
- package/.github/workflows/ci.yml +69 -0
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -316
- package/README.md +148 -4
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +856 -120
- package/lib/api.js +239 -50
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +489 -0
- package/lib/commands/chat-slash.js +415 -0
- package/lib/commands/chat-turn.js +669 -0
- package/lib/commands/chat.js +407 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +360 -11
- package/lib/constants.js +401 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +202 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +270 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +123 -26
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +99 -8
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2862 -0
- package/lib/tool_specs.js +263 -9
- package/lib/tools.js +352 -1039
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +236 -0
- package/lib/ui/format.js +195 -29
- package/lib/ui/input-field.js +21 -11
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +146 -36
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -44
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +270 -0
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/background.test.js +414 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +599 -0
- package/test/extract-tool-calls.test.js +349 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/file-activity.test.js +522 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +143 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +348 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +218 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/md-stream.test.js +183 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +409 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +362 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/result-cap.test.js +233 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +171 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/theme-palette.test.js +166 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +203 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1438
- package/path +0 -1
package/lib/sdk.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Embedding SDK — the STABLE public facade (Task 5.2)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
//
|
|
7
|
+
// `createAgent(options)` is the supported, semver-stable way to embed the agent
|
|
8
|
+
// in another program. It encapsulates assembly — api client, tool registry,
|
|
9
|
+
// permission manager, sandbox, config — behind a small object:
|
|
10
|
+
//
|
|
11
|
+
// const { createAgent } = require('@semalt-ai/code');
|
|
12
|
+
// const agent = createAgent({ apiBase, apiKey, model });
|
|
13
|
+
// const res = await agent.run('Summarise README.md');
|
|
14
|
+
// await agent.close();
|
|
15
|
+
//
|
|
16
|
+
// The shape returned by `run()` is the same structured envelope headless mode
|
|
17
|
+
// produces: { result, toolCalls, usage, cost, stopReason, verifyStatus }
|
|
18
|
+
// (plus `messages` for multi-turn continuation).
|
|
19
|
+
//
|
|
20
|
+
// SECURITY — defaults safe (constraint 2). There is no TTY in embedded use, so
|
|
21
|
+
// the facade takes a programmatic permission policy:
|
|
22
|
+
// * `approve(call) => boolean|Promise<boolean>` — an async approver, OR
|
|
23
|
+
// * `rules: [...]` — preset allow/deny/ask rules (the Task 4.1 engine).
|
|
24
|
+
// With NEITHER provided the default is to REFUSE every mutating/effectful tool
|
|
25
|
+
// (mirroring non-TTY), never auto-approve. The OS sandbox and the destructive-
|
|
26
|
+
// command deny-list stay ON; disabling them is an explicit, documented opt-in
|
|
27
|
+
// (`sandbox: { mode: 'off' }`, and the process-global
|
|
28
|
+
// `--dangerously-skip-permissions`), never a side effect of embedding.
|
|
29
|
+
//
|
|
30
|
+
// LIFECYCLE (constraint 3). `createAgent` may open resources (MCP servers).
|
|
31
|
+
// Hosts MUST call `close()` to release them.
|
|
32
|
+
//
|
|
33
|
+
// The unstable building blocks (runAgentLoop, createApiClient, the registries)
|
|
34
|
+
// live behind the separate `@semalt-ai/code/internals` subpath — see
|
|
35
|
+
// lib/internals.js.
|
|
36
|
+
|
|
37
|
+
const { EventEmitter } = require('events');
|
|
38
|
+
|
|
39
|
+
const { DEFAULT_CONFIG } = require('./constants');
|
|
40
|
+
const { normalizeConfig, readUserConfig, loadProjectConfig, resolveMaxIterations, isNativeToolsActive } = require('./config');
|
|
41
|
+
const { loadRuleLayers } = require('./permission-rules');
|
|
42
|
+
const ui = require('./ui');
|
|
43
|
+
const { createPermissionManager } = require('./permissions');
|
|
44
|
+
const { createToolExecutor, extractToolCalls, setUIActive, isUIActive } = require('./tools');
|
|
45
|
+
const { createApiClient } = require('./api');
|
|
46
|
+
const { createAgentRunner } = require('./agent');
|
|
47
|
+
const { createHeadlessSink } = require('./headless');
|
|
48
|
+
|
|
49
|
+
const TIER_NAMES = new Set(['fs', 'exec', 'net', 'sys']);
|
|
50
|
+
|
|
51
|
+
// Resolve the requested permission tiers into the allowedTiers array the
|
|
52
|
+
// PermissionManager expects. Accepts 'all', a single tier string, or an array.
|
|
53
|
+
function resolveTiers(allow) {
|
|
54
|
+
if (!allow) return [];
|
|
55
|
+
if (allow === 'all') return ['fs', 'exec', 'net', 'sys'];
|
|
56
|
+
const arr = Array.isArray(allow) ? allow : [allow];
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const a of arr) {
|
|
59
|
+
if (a === 'all') return ['fs', 'exec', 'net', 'sys'];
|
|
60
|
+
if (TIER_NAMES.has(a)) out.push(a);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Build the user-layer rule set from a host-supplied `rules` option. Host rules
|
|
66
|
+
// are TRUSTED (the embedding program authored them), so they map to the USER
|
|
67
|
+
// layer — they may widen, unlike project rules. A project `.semalt/config.json`
|
|
68
|
+
// (loaded when `loadProjectRules` is on) can still only NARROW, exactly as the
|
|
69
|
+
// CLI enforces.
|
|
70
|
+
function buildRuleLayers({ rules, cwd, loadProjectRules }) {
|
|
71
|
+
let userRules = [];
|
|
72
|
+
if (Array.isArray(rules)) userRules = rules;
|
|
73
|
+
else if (rules && Array.isArray(rules.user)) userRules = rules.user;
|
|
74
|
+
const projectRules = (rules && Array.isArray(rules.project)) ? rules.project : [];
|
|
75
|
+
const userCfg = { permissions: { rules: userRules } };
|
|
76
|
+
let projectCfg = { permissions: { rules: projectRules } };
|
|
77
|
+
if (loadProjectRules) {
|
|
78
|
+
const fromDisk = loadProjectConfig(cwd) || {};
|
|
79
|
+
const diskRules = (fromDisk.permissions && Array.isArray(fromDisk.permissions.rules)) ? fromDisk.permissions.rules : [];
|
|
80
|
+
projectCfg = { permissions: { rules: projectRules.concat(diskRules) } };
|
|
81
|
+
}
|
|
82
|
+
return loadRuleLayers(userCfg, projectCfg, () => {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Assemble a per-instance, normalized config object. By default the host's
|
|
86
|
+
// ~/.semalt-ai/config.json is NOT read (a server embedding the SDK wants
|
|
87
|
+
// isolation, not the operator's personal defaults); pass `loadUserConfig: true`
|
|
88
|
+
// to layer it in. Explicit options always win.
|
|
89
|
+
function buildConfig(options) {
|
|
90
|
+
const {
|
|
91
|
+
apiBase, apiKey, dashboardUrl, model, contextLength,
|
|
92
|
+
config: configOverride = {},
|
|
93
|
+
loadUserConfig = false,
|
|
94
|
+
sandbox,
|
|
95
|
+
maxIterations,
|
|
96
|
+
} = options;
|
|
97
|
+
|
|
98
|
+
const base = loadUserConfig ? readUserConfig() : {};
|
|
99
|
+
const merged = { ...DEFAULT_CONFIG, ...base, ...configOverride };
|
|
100
|
+
if (apiBase != null) merged.api_base = apiBase;
|
|
101
|
+
if (apiKey != null) merged.api_key = apiKey;
|
|
102
|
+
if (dashboardUrl != null) merged.dashboard_url = dashboardUrl;
|
|
103
|
+
if (model != null) merged.default_model = model;
|
|
104
|
+
if (contextLength != null) merged.context_length = contextLength;
|
|
105
|
+
if (sandbox && typeof sandbox === 'object') merged.sandbox = { ...(merged.sandbox || {}), ...sandbox };
|
|
106
|
+
if (maxIterations != null) merged.max_iterations = maxIterations;
|
|
107
|
+
return normalizeConfig(merged);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createAgent(options = {}) {
|
|
111
|
+
const cwd = options.cwd || process.cwd();
|
|
112
|
+
|
|
113
|
+
// Per-instance config behind a closure (NOT the module-global in index.js), so
|
|
114
|
+
// two createAgent() instances never share config state.
|
|
115
|
+
let config = buildConfig(options);
|
|
116
|
+
const getConfig = () => config;
|
|
117
|
+
// setConfig stays in-memory only — the SDK never writes the operator's
|
|
118
|
+
// config.json (e.g. a learned context-length is kept for this instance alone).
|
|
119
|
+
const setConfig = (next) => { config = normalizeConfig({ ...config, ...next }); };
|
|
120
|
+
|
|
121
|
+
const emitter = new EventEmitter();
|
|
122
|
+
// Don't let a missing/throwing 'error' listener crash the host process — the
|
|
123
|
+
// run result already carries failures; events are advisory.
|
|
124
|
+
emitter.on('error', () => {});
|
|
125
|
+
|
|
126
|
+
// ── Permission perimeter (defaults safe) ────────────────────────────────
|
|
127
|
+
const allowedTiers = resolveTiers(options.allow);
|
|
128
|
+
const ruleLayers = buildRuleLayers({
|
|
129
|
+
rules: options.rules,
|
|
130
|
+
cwd,
|
|
131
|
+
loadProjectRules: options.loadProjectRules === true,
|
|
132
|
+
});
|
|
133
|
+
// The host-supplied async approver. We ALWAYS install one (the host's, or a
|
|
134
|
+
// refuse-by-default fallback) so the embedded gate decision is deterministic
|
|
135
|
+
// and never falls through to the interactive TTY prompt — even if the host
|
|
136
|
+
// process happens to have a TTY. The approver is consulted only AFTER an allow
|
|
137
|
+
// tier / allow rule has had its say, so with no policy at all it refuses every
|
|
138
|
+
// mutating tool: the safe embedded default.
|
|
139
|
+
const approver = typeof options.approve === 'function'
|
|
140
|
+
? (call) => options.approve(call)
|
|
141
|
+
: () => false;
|
|
142
|
+
// dangerouslySkipPermissions is the programmatic equivalent of the human-only
|
|
143
|
+
// CLI flag: it auto-approves the GATE. NOTE it does NOT disable the deny-list
|
|
144
|
+
// / secret-read / config-write guards — those read the process argv once at
|
|
145
|
+
// module load (see lib/tools.js); turning them off requires launching the host
|
|
146
|
+
// process with --dangerously-skip-permissions. This asymmetry is documented in
|
|
147
|
+
// the README ("Multi-instance & process-global state").
|
|
148
|
+
const skipPermissions = options.dangerouslySkipPermissions === true;
|
|
149
|
+
|
|
150
|
+
const permissionManager = createPermissionManager(ui, {
|
|
151
|
+
allowedTiers,
|
|
152
|
+
readonly: options.readonly === true,
|
|
153
|
+
skipPermissions,
|
|
154
|
+
rules: ruleLayers,
|
|
155
|
+
cwd,
|
|
156
|
+
approver,
|
|
157
|
+
// Embedded: keep stdout byte-clean — the host gets denials in the result.
|
|
158
|
+
quiet: true,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── OS-sandbox fallback approver ─────────────────────────────────────────
|
|
162
|
+
// When the kernel sandbox is unavailable in `auto` mode, this decides whether
|
|
163
|
+
// to run a command unsandboxed. It lives here (NOT anywhere the model can
|
|
164
|
+
// reach). Default: refuse (return false) — never a silent unsandboxed run.
|
|
165
|
+
// A host opts into running unsandboxed by passing `onUnsandboxed`.
|
|
166
|
+
const onUnsandboxed = typeof options.onUnsandboxed === 'function'
|
|
167
|
+
? options.onUnsandboxed
|
|
168
|
+
: async () => false;
|
|
169
|
+
|
|
170
|
+
const apiClient = createApiClient({ getConfig, saveConfig: setConfig, ui });
|
|
171
|
+
|
|
172
|
+
const { runAgentLoop } = createAgentRunner({
|
|
173
|
+
chatStream: apiClient.chatStream,
|
|
174
|
+
extractToolCalls: (reply, opts = {}) => extractToolCalls(reply, {
|
|
175
|
+
repairMalformedXml: !!getConfig().repair_malformed_tool_xml,
|
|
176
|
+
...opts,
|
|
177
|
+
}),
|
|
178
|
+
...createToolExecutor(permissionManager, ui, getConfig, {
|
|
179
|
+
onUnsandboxed,
|
|
180
|
+
// Web-fetch secondary summarizer (Task W.1) — same chatComplete-backed
|
|
181
|
+
// call the CLI wires; http_get summarizes extracted content in isolation.
|
|
182
|
+
webChat: (messages, opts) => apiClient.chatComplete(messages, opts),
|
|
183
|
+
// Web search (Task W.2b) — the web_search tool calls the backend
|
|
184
|
+
// /api/search via dashboardSearch; compact snippets, not full pages.
|
|
185
|
+
webSearch: (query, opts) => apiClient.dashboardSearch(query, opts),
|
|
186
|
+
}),
|
|
187
|
+
permissionManager,
|
|
188
|
+
ui,
|
|
189
|
+
getConfig,
|
|
190
|
+
onUnsandboxed,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── MCP (optional, lazily connected) ─────────────────────────────────────
|
|
194
|
+
// Connected on first run() when config.mcp.servers is non-empty, so close()
|
|
195
|
+
// has something to tear down. NOTE: the dynamic tool registry MCP tools land
|
|
196
|
+
// in is PROCESS-GLOBAL (lib/tool_registry.js _dynamic) — concurrent instances
|
|
197
|
+
// with different MCP servers would see each other's tools. Documented limit.
|
|
198
|
+
let mcpManager = null;
|
|
199
|
+
let mcpConnected = false;
|
|
200
|
+
async function ensureMcp() {
|
|
201
|
+
const servers = (getConfig().mcp && getConfig().mcp.servers) || {};
|
|
202
|
+
if (!Object.keys(servers).length) return;
|
|
203
|
+
if (!mcpManager) {
|
|
204
|
+
const { createMcpManager } = require('./mcp/client');
|
|
205
|
+
mcpManager = createMcpManager({ getConfig, logger: (m) => emitter.emit('warning', m) });
|
|
206
|
+
}
|
|
207
|
+
if (!mcpConnected) {
|
|
208
|
+
await mcpManager.connectAll();
|
|
209
|
+
mcpConnected = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let closed = false;
|
|
214
|
+
let memoryWarned = false;
|
|
215
|
+
|
|
216
|
+
async function run(prompt, runOpts = {}) {
|
|
217
|
+
if (closed) throw new Error('createAgent: this agent has been close()d');
|
|
218
|
+
await ensureMcp();
|
|
219
|
+
|
|
220
|
+
// Fail-loud (memory truncation): the agent's system prompt loads project
|
|
221
|
+
// memory (AGENTS.md/CLAUDE.md) from disk; if a file was cut at the cap, warn
|
|
222
|
+
// the host once via the 'warning' event (same channel as MCP warnings) —
|
|
223
|
+
// user-facing, never added to the model prompt. Once per agent, not per run.
|
|
224
|
+
if (!memoryWarned) {
|
|
225
|
+
memoryWarned = true;
|
|
226
|
+
try {
|
|
227
|
+
const { loadProjectMemory, memoryTruncationWarnings } = require('./memory');
|
|
228
|
+
for (const w of memoryTruncationWarnings(loadProjectMemory())) emitter.emit('warning', w);
|
|
229
|
+
} catch { /* best-effort; never block a run */ }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const model = runOpts.model || getConfig().default_model;
|
|
233
|
+
const messages = Array.isArray(runOpts.messages)
|
|
234
|
+
? runOpts.messages.slice()
|
|
235
|
+
: [{ role: 'user', content: String(prompt == null ? '' : prompt) }];
|
|
236
|
+
|
|
237
|
+
// Multimodal image input (Task 5.4). `images` accepts file paths or
|
|
238
|
+
// already-encoded { media_type, data } records; each is read through the
|
|
239
|
+
// size + path guards and attached to the latest user turn. A bad image
|
|
240
|
+
// throws here (the host gets a clear error), never a silent drop.
|
|
241
|
+
if (runOpts.images && runOpts.images.length) {
|
|
242
|
+
const { resolveImageInputs, attachImagesToLastUser } = require('./images');
|
|
243
|
+
const { isPathSafe } = require('./tools');
|
|
244
|
+
const imgs = resolveImageInputs(runOpts.images, { maxBytes: getConfig().image_max_bytes, isPathSafe });
|
|
245
|
+
attachImagesToLastUser(messages, imgs);
|
|
246
|
+
}
|
|
247
|
+
const maxIter = runOpts.maxIterations != null
|
|
248
|
+
? resolveMaxIterations(runOpts.maxIterations)
|
|
249
|
+
: resolveMaxIterations(getConfig().max_iterations);
|
|
250
|
+
const tokenLimit = runOpts.tokenLimit != null ? runOpts.tokenLimit : (getConfig().context_length || null);
|
|
251
|
+
|
|
252
|
+
// Reuse the headless json sink to build the documented result envelope
|
|
253
|
+
// (result / toolCalls / usage / cost / stopReason / verifyStatus). We
|
|
254
|
+
// capture the object instead of writing a JSON line to stdout.
|
|
255
|
+
let envelope = null;
|
|
256
|
+
const sink = createHeadlessSink('json', (obj) => { envelope = obj; }, {
|
|
257
|
+
model,
|
|
258
|
+
priceOverrides: getConfig().pricing || {},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Forward streaming activity to the event emitter (advisory; the result is
|
|
262
|
+
// authoritative). Merge AFTER the sink's machine callbacks so both run.
|
|
263
|
+
const eventCallbacks = {
|
|
264
|
+
onToken: (t) => emitter.emit('token', t),
|
|
265
|
+
onAssistantMessage: (m) => emitter.emit('assistant', m),
|
|
266
|
+
onToolStart: (ctx) => emitter.emit('tool-start', ctx),
|
|
267
|
+
onToolEnd: (tag, resultStr, ms, meta) => emitter.emit('tool', { tag, result: resultStr, ms, meta }),
|
|
268
|
+
onError: (e) => emitter.emit('error', e),
|
|
269
|
+
};
|
|
270
|
+
const callbacks = {};
|
|
271
|
+
for (const key of new Set([...Object.keys(eventCallbacks), ...Object.keys(sink.callbacks)])) {
|
|
272
|
+
const a = eventCallbacks[key];
|
|
273
|
+
const b = sink.callbacks[key];
|
|
274
|
+
callbacks[key] = (...args) => { if (a) a(...args); if (b) b(...args); };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Keep the host's stdout byte-clean: suppress the tool ✓/✗ lines and the
|
|
278
|
+
// write/append permission diff for the duration (the same flag headless
|
|
279
|
+
// machine modes use). NOTE this flag is process-global (lib/tools.js) — two
|
|
280
|
+
// instances running CONCURRENTLY share it; documented under multi-instance.
|
|
281
|
+
const prevUIActive = isUIActive();
|
|
282
|
+
setUIActive(true);
|
|
283
|
+
let res;
|
|
284
|
+
try {
|
|
285
|
+
res = await runAgentLoop(messages, model, maxIter, tokenLimit, {
|
|
286
|
+
callbacks,
|
|
287
|
+
systemPrompt: runOpts.systemPrompt != null ? runOpts.systemPrompt : null,
|
|
288
|
+
systemPromptMode: getConfig().system_prompt_mode || 'system_role',
|
|
289
|
+
planMode: !!runOpts.planMode,
|
|
290
|
+
noVerify: !!runOpts.noVerify,
|
|
291
|
+
debug: false,
|
|
292
|
+
});
|
|
293
|
+
} finally {
|
|
294
|
+
setUIActive(prevUIActive);
|
|
295
|
+
}
|
|
296
|
+
sink.finalize(res);
|
|
297
|
+
emitter.emit('done', envelope);
|
|
298
|
+
// Attach the running message history so a host can continue the conversation
|
|
299
|
+
// by passing { messages } back into a subsequent run().
|
|
300
|
+
return { ...envelope, messages: res.messages };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function on(event, cb) { emitter.on(event, cb); return handle; }
|
|
304
|
+
function off(event, cb) { emitter.off(event, cb); return handle; }
|
|
305
|
+
|
|
306
|
+
async function close() {
|
|
307
|
+
if (closed) return;
|
|
308
|
+
closed = true;
|
|
309
|
+
if (mcpManager) {
|
|
310
|
+
try { await mcpManager.shutdown(); } catch { /* best-effort */ }
|
|
311
|
+
}
|
|
312
|
+
emitter.removeAllListeners();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const handle = {
|
|
316
|
+
run,
|
|
317
|
+
on,
|
|
318
|
+
off,
|
|
319
|
+
close,
|
|
320
|
+
// Read-only introspection — handy for hosts; not a stability-critical surface.
|
|
321
|
+
getConfig,
|
|
322
|
+
get cwd() { return cwd; },
|
|
323
|
+
get closed() { return closed; },
|
|
324
|
+
};
|
|
325
|
+
return handle;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
module.exports = { createAgent };
|
package/lib/secrets.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// API-key sourcing — env var → OS keychain → config file
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
//
|
|
7
|
+
// Precedence (highest first):
|
|
8
|
+
// 1. SEMALT_API_KEY environment variable
|
|
9
|
+
// 2. OS keychain (macOS Keychain / Linux libsecret / Windows Credential Mgr)
|
|
10
|
+
// 3. api_key in ~/.semalt-ai/config.json (plaintext fallback)
|
|
11
|
+
//
|
|
12
|
+
// Keys sourced from (1) or (2) are NEVER written back to config.json and are
|
|
13
|
+
// reported as redacted by configShow — only the source label is surfaced.
|
|
14
|
+
//
|
|
15
|
+
// Keychain access shells out to the platform's native tool (no npm deps). If
|
|
16
|
+
// the tool is missing or fails, we degrade gracefully and fall through to the
|
|
17
|
+
// next source rather than crashing.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const { spawnSync } = require('child_process');
|
|
21
|
+
|
|
22
|
+
const ENV_VAR = 'SEMALT_API_KEY';
|
|
23
|
+
const KEYCHAIN_SERVICE = 'semalt-code';
|
|
24
|
+
const KEYCHAIN_ACCOUNT = 'api_key';
|
|
25
|
+
|
|
26
|
+
// Process-lifetime cache so we don't re-shell to the keychain on every request.
|
|
27
|
+
// undefined = not yet resolved. { source, key } once resolved.
|
|
28
|
+
let _cache;
|
|
29
|
+
|
|
30
|
+
function _run(cmd, args, input) {
|
|
31
|
+
try {
|
|
32
|
+
const res = spawnSync(cmd, args, {
|
|
33
|
+
input: input === undefined ? undefined : input,
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
timeout: 5000,
|
|
36
|
+
windowsHide: true,
|
|
37
|
+
});
|
|
38
|
+
if (!res || res.error) return null;
|
|
39
|
+
if (res.status !== 0) return null;
|
|
40
|
+
return typeof res.stdout === 'string' ? res.stdout : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Strip only a single trailing CR/LF that the keychain tool appends — never
|
|
47
|
+
// trim interior or leading characters, which could be meaningful in a key.
|
|
48
|
+
function _stripTrailingNewline(s) {
|
|
49
|
+
return typeof s === 'string' ? s.replace(/\r?\n$/, '') : s;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Generic keychain read for an arbitrary (service, account). Returns the stored
|
|
53
|
+
// secret string or null. The platform branches mirror the API-key path; the
|
|
54
|
+
// MCP OAuth token store (lib/mcp/oauth.js) reuses this so OAuth tokens land in
|
|
55
|
+
// the OS keychain, never in plaintext config.
|
|
56
|
+
function keychainGetItem(service, account) {
|
|
57
|
+
if (!service || !account) return null;
|
|
58
|
+
const platform = process.platform;
|
|
59
|
+
if (platform === 'darwin') {
|
|
60
|
+
const out = _run('security', [
|
|
61
|
+
'find-generic-password', '-s', service, '-a', account, '-w',
|
|
62
|
+
]);
|
|
63
|
+
const v = _stripTrailingNewline(out);
|
|
64
|
+
return v && v.length ? v : null;
|
|
65
|
+
}
|
|
66
|
+
if (platform === 'linux') {
|
|
67
|
+
const out = _run('secret-tool', [
|
|
68
|
+
'lookup', 'service', service, 'account', account,
|
|
69
|
+
]);
|
|
70
|
+
const v = _stripTrailingNewline(out);
|
|
71
|
+
return v && v.length ? v : null;
|
|
72
|
+
}
|
|
73
|
+
if (platform === 'win32') {
|
|
74
|
+
const ps =
|
|
75
|
+
`try {` +
|
|
76
|
+
`[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime];` +
|
|
77
|
+
`$v = New-Object Windows.Security.Credentials.PasswordVault;` +
|
|
78
|
+
`$c = $v.Retrieve('${service}','${account}');` +
|
|
79
|
+
`$c.RetrievePassword(); Write-Output $c.Password` +
|
|
80
|
+
`} catch { exit 1 }`;
|
|
81
|
+
const out = _run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
|
|
82
|
+
const v = _stripTrailingNewline(out);
|
|
83
|
+
return v && v.trim().length ? v.trim() : null;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Generic keychain write for an arbitrary (service, account). Returns true on
|
|
89
|
+
// success, false if the platform tool is unavailable or the store failed.
|
|
90
|
+
function keychainSetItem(service, account, value, label) {
|
|
91
|
+
if (!service || !account || typeof value !== 'string' || !value) return false;
|
|
92
|
+
const platform = process.platform;
|
|
93
|
+
if (platform === 'darwin') {
|
|
94
|
+
const out = _run('security', [
|
|
95
|
+
'add-generic-password', '-U', '-s', service, '-a', account, '-w', value,
|
|
96
|
+
]);
|
|
97
|
+
return out !== null;
|
|
98
|
+
}
|
|
99
|
+
if (platform === 'linux') {
|
|
100
|
+
const out = _run('secret-tool', [
|
|
101
|
+
'store', '--label', label || `${service} ${account}`,
|
|
102
|
+
'service', service, 'account', account,
|
|
103
|
+
], value);
|
|
104
|
+
return out !== null;
|
|
105
|
+
}
|
|
106
|
+
if (platform === 'win32') {
|
|
107
|
+
const escaped = value.replace(/'/g, "''");
|
|
108
|
+
const ps =
|
|
109
|
+
`try {` +
|
|
110
|
+
`[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime];` +
|
|
111
|
+
`$v = New-Object Windows.Security.Credentials.PasswordVault;` +
|
|
112
|
+
`$cred = New-Object Windows.Security.Credentials.PasswordCredential('${service}','${account}','${escaped}');` +
|
|
113
|
+
`$v.Add($cred)` +
|
|
114
|
+
`} catch { exit 1 }`;
|
|
115
|
+
const out = _run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
|
|
116
|
+
return out !== null;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Generic keychain delete for an arbitrary (service, account). Best-effort;
|
|
122
|
+
// returns true if the platform tool reported success.
|
|
123
|
+
function keychainDeleteItem(service, account) {
|
|
124
|
+
if (!service || !account) return false;
|
|
125
|
+
const platform = process.platform;
|
|
126
|
+
if (platform === 'darwin') {
|
|
127
|
+
const out = _run('security', ['delete-generic-password', '-s', service, '-a', account]);
|
|
128
|
+
return out !== null;
|
|
129
|
+
}
|
|
130
|
+
if (platform === 'linux') {
|
|
131
|
+
const out = _run('secret-tool', ['clear', 'service', service, 'account', account]);
|
|
132
|
+
return out !== null;
|
|
133
|
+
}
|
|
134
|
+
if (platform === 'win32') {
|
|
135
|
+
const ps =
|
|
136
|
+
`try {` +
|
|
137
|
+
`[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime];` +
|
|
138
|
+
`$v = New-Object Windows.Security.Credentials.PasswordVault;` +
|
|
139
|
+
`$c = $v.Retrieve('${service}','${account}');` +
|
|
140
|
+
`$v.Remove($c)` +
|
|
141
|
+
`} catch { exit 1 }`;
|
|
142
|
+
const out = _run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
|
|
143
|
+
return out !== null;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Read the API key from the OS keychain. Returns the key string or null.
|
|
149
|
+
function keychainGet() {
|
|
150
|
+
return keychainGetItem(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Store the API key in the OS keychain. Returns true on success, false if the
|
|
154
|
+
// platform tool is unavailable or the store failed.
|
|
155
|
+
function keychainSet(key) {
|
|
156
|
+
if (typeof key !== 'string' || !key) return false;
|
|
157
|
+
const stored = keychainSetItem(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, key, 'semalt-code API key');
|
|
158
|
+
if (stored) { _cache = undefined; return true; }
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Resolve the active API key following the documented precedence. `config` is
|
|
163
|
+
// the loaded config object (used only for the plaintext fallback). The result
|
|
164
|
+
// is cached for the env/keychain sources; the config fallback is re-read each
|
|
165
|
+
// call so a runtime `config set api_key` still takes effect.
|
|
166
|
+
function resolveApiKey(config) {
|
|
167
|
+
if (_cache === undefined) {
|
|
168
|
+
const env = process.env[ENV_VAR];
|
|
169
|
+
if (env && env.trim()) {
|
|
170
|
+
_cache = { source: 'env', key: env };
|
|
171
|
+
} else {
|
|
172
|
+
const kc = keychainGet();
|
|
173
|
+
if (kc && kc.trim()) {
|
|
174
|
+
_cache = { source: 'keychain', key: kc };
|
|
175
|
+
} else {
|
|
176
|
+
_cache = { source: null, key: null };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (_cache.key) return _cache.key;
|
|
181
|
+
return (config && typeof config.api_key === 'string') ? config.api_key : '';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Report which source the active API key comes from: 'env' | 'keychain' |
|
|
185
|
+
// 'config' | 'none'. Used by configShow so users can see where their key
|
|
186
|
+
// resolves from without ever printing the key itself.
|
|
187
|
+
function apiKeySource(config) {
|
|
188
|
+
if (_cache === undefined) resolveApiKey(config);
|
|
189
|
+
if (_cache.source) return _cache.source;
|
|
190
|
+
const cfgKey = config && typeof config.api_key === 'string' ? config.api_key : '';
|
|
191
|
+
return cfgKey ? 'config' : 'none';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Test/CLI helper: forget the cached resolution (e.g. right after storing a new
|
|
195
|
+
// key) so the next resolveApiKey() re-reads env/keychain.
|
|
196
|
+
function _clearCache() { _cache = undefined; }
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
ENV_VAR,
|
|
200
|
+
KEYCHAIN_SERVICE,
|
|
201
|
+
KEYCHAIN_ACCOUNT,
|
|
202
|
+
resolveApiKey,
|
|
203
|
+
apiKeySource,
|
|
204
|
+
keychainGet,
|
|
205
|
+
keychainSet,
|
|
206
|
+
// Generic keychain item access (Task 3.3) — used by the MCP OAuth token store.
|
|
207
|
+
keychainGetItem,
|
|
208
|
+
keychainSetItem,
|
|
209
|
+
keychainDeleteItem,
|
|
210
|
+
_clearCache,
|
|
211
|
+
};
|