shraga 0.1.26 → 0.1.28
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/server/claude.ts
CHANGED
|
@@ -74,7 +74,7 @@ export type WsEvent =
|
|
|
74
74
|
| { type: 'text_delta'; text: string }
|
|
75
75
|
| { type: 'tool_use'; tool: string; toolUseId: string; input: unknown }
|
|
76
76
|
| { type: 'tool_use_input'; toolUseId: string; input: unknown }
|
|
77
|
-
| { type: 'tool_result'; toolUseId: string; output: string }
|
|
77
|
+
| { type: 'tool_result'; toolUseId: string; output: string; isError?: boolean }
|
|
78
78
|
| { type: 'tool_result_image'; toolUseId: string; dataUrl: string }
|
|
79
79
|
| { type: 'permission_request'; id: string; tool: string; input: Record<string, unknown> }
|
|
80
80
|
| { type: 'question_request'; id: string; questions: AskQuestion[] }
|
|
@@ -14,6 +14,7 @@ import type { WsEvent, AskQuestion, QuestionAnswers, QuestionHandler } from '../
|
|
|
14
14
|
import type { AgentEngine, EngineStreamOpts, EngineModel } from './types.ts';
|
|
15
15
|
import { getPromptSuffix } from '../prompt-suffix.ts';
|
|
16
16
|
import { APP_ROOT } from '../paths.ts';
|
|
17
|
+
import { writeMcpConfigFile } from './mcp-config-file.ts';
|
|
17
18
|
const IMMUTABLE_SYSTEM_PROMPT = readFileSync(path.resolve(import.meta.dirname, '../../../defaults/system-prompt.md'), 'utf-8');
|
|
18
19
|
const DEFAULT_USER_PROMPT = `You are a helpful assistant with access to MCP tools.`;
|
|
19
20
|
const DEFAULT_ALLOWED_TOOLS = ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'];
|
|
@@ -271,7 +272,14 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
271
272
|
const addonSuffix = getPromptSuffix(opts.turnHints);
|
|
272
273
|
options['systemPrompt'] = `${IMMUTABLE_SYSTEM_PROMPT}\n\n${userPrompt}${addonSuffix ? `\n\n${addonSuffix}` : ''}`;
|
|
273
274
|
if (opts.abortController) options['abortController'] = opts.abortController;
|
|
274
|
-
|
|
275
|
+
// Passed as a FILE, never as `options.mcpServers` — the SDK would put the whole config (every MCP
|
|
276
|
+
// server's credentials) on the CLI's argv, where `ps` / `/proc` / journald expose it. See
|
|
277
|
+
// writeMcpConfigFile. Setting both would re-add the argv copy, so it's one or the other.
|
|
278
|
+
let mcpConfigFile: { path: string; cleanup: () => void } | undefined;
|
|
279
|
+
if (opts.mcpServers && Object.keys(opts.mcpServers).length > 0) {
|
|
280
|
+
mcpConfigFile = writeMcpConfigFile(opts.mcpServers);
|
|
281
|
+
options['extraArgs'] = { ...(options['extraArgs'] as Record<string, string> | undefined), 'mcp-config': mcpConfigFile.path };
|
|
282
|
+
}
|
|
275
283
|
|
|
276
284
|
const mcpNames = opts.mcpServers ? Object.keys(opts.mcpServers) : [];
|
|
277
285
|
const activeModel = (options['model'] as string) || 'default';
|
|
@@ -446,7 +454,9 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
446
454
|
const output = contentArr.length > 0
|
|
447
455
|
? contentArr.filter((c: any) => c.type === 'text').map((c: any) => c.text ?? '').join('')
|
|
448
456
|
: String(block.content ?? '');
|
|
449
|
-
|
|
457
|
+
// `is_error` is the ONLY signal that a tool failed — the text alone is indistinguishable
|
|
458
|
+
// from a successful result. Scheduled `bash` tasks rely on it to notice a non-zero exit.
|
|
459
|
+
yield { type: 'tool_result', toolUseId: String(block.tool_use_id), output, isError: block.is_error === true };
|
|
450
460
|
|
|
451
461
|
let foundImage = false;
|
|
452
462
|
for (const c of contentArr) {
|
|
@@ -515,6 +525,9 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
515
525
|
return;
|
|
516
526
|
} finally {
|
|
517
527
|
clearBgTimer();
|
|
528
|
+
// The CLI has read the file by now (it loads MCP config at startup); holding it any longer just
|
|
529
|
+
// widens the window in which the credentials sit on disk.
|
|
530
|
+
mcpConfigFile?.cleanup();
|
|
518
531
|
}
|
|
519
532
|
|
|
520
533
|
const inferredReason = turnCount >= maxTurns ? 'max_turns_reached' : 'end_turn';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { McpConfig } from '../mcp.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hands the MCP config to the Claude Code CLI through a private FILE instead of its argv.
|
|
8
|
+
*
|
|
9
|
+
* WHY: the SDK serialises `options.mcpServers` straight onto the command line
|
|
10
|
+
* (`--mcp-config '{"mcpServers":{…}}'`), and an MCP server's `env` block is where every vendor
|
|
11
|
+
* credential lives. On the Circles box that meant the Stripe live key, a GitHub PAT with destructive
|
|
12
|
+
* writes, two Firebase service-account private keys, the prod Postgres password + bastion SSH key and
|
|
13
|
+
* the app-store signing keys were all readable by ANY local process via `ps` / `/proc/<pid>/cmdline`,
|
|
14
|
+
* and were echoed verbatim into journald (so into anything that ships logs). Argv is not a secret
|
|
15
|
+
* channel — a file we own with 0600 is.
|
|
16
|
+
*
|
|
17
|
+
* HOW: the CLI's `--mcp-config` takes either inline JSON or a path, so we write the same JSON to a
|
|
18
|
+
* 0600 file inside a 0700 temp dir and pass the path via the SDK's `extraArgs` escape hatch. The
|
|
19
|
+
* caller must NOT also set `options.mcpServers`, or the SDK appends a second `--mcp-config` with the
|
|
20
|
+
* secrets back in argv. Safe here because shraga only ships `stdio` + `http` servers; an in-process
|
|
21
|
+
* `sdk` server would have to stay on `options.mcpServers` (it holds no secrets — it's a live object).
|
|
22
|
+
*/
|
|
23
|
+
export function writeMcpConfigFile(mcpServers: McpConfig): { path: string; cleanup: () => void } {
|
|
24
|
+
// mkdtemp gives us a 0700 dir with an unguessable name, so the file is unreachable even in the
|
|
25
|
+
// window before the mode is applied, and two concurrent sessions can never collide.
|
|
26
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'shraga-mcp-'));
|
|
27
|
+
const file = path.join(dir, 'mcp-config.json');
|
|
28
|
+
writeFileSync(file, JSON.stringify({ mcpServers }), { mode: 0o600 });
|
|
29
|
+
return {
|
|
30
|
+
path: file,
|
|
31
|
+
// Best-effort: a leaked temp dir is a much smaller problem than a crash on teardown, and the
|
|
32
|
+
// 0700/0600 modes mean a leftover file is still unreadable by other users.
|
|
33
|
+
cleanup: () => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* nothing to do */ } },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -194,12 +194,21 @@ export async function runSchedule(
|
|
|
194
194
|
let status: ScheduleRunSummary['status'] = 'ok';
|
|
195
195
|
let error: string | undefined;
|
|
196
196
|
|
|
197
|
+
// A `bash` task is not exec'd — it's handed to the agent as a prompt, so the command's exit code
|
|
198
|
+
// reaches nobody: the agent reports the failure in prose, its own turn succeeds, the run is stored
|
|
199
|
+
// `ok`, and the failure notifier never fires. Track the tool_result of the task's OWN Bash call and
|
|
200
|
+
// fail the run with it, so a broken scheduled script alerts like a `job` does.
|
|
201
|
+
const taskBashToolUseIds = new Set<string>();
|
|
202
|
+
let bashFailure: string | undefined;
|
|
203
|
+
|
|
197
204
|
onEvent({ type: 'schedule:run_started', scheduleId: schedule.id, sessionId, at: now });
|
|
198
205
|
|
|
199
206
|
try {
|
|
200
207
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
201
208
|
status = 'ok';
|
|
202
209
|
error = undefined;
|
|
210
|
+
bashFailure = undefined;
|
|
211
|
+
taskBashToolUseIds.clear();
|
|
203
212
|
try {
|
|
204
213
|
for await (const ev of streamChat({
|
|
205
214
|
prompt,
|
|
@@ -217,10 +226,12 @@ export async function runSchedule(
|
|
|
217
226
|
} else if (ev.type === 'tool_use') {
|
|
218
227
|
if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
|
|
219
228
|
assistantBlocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
|
|
229
|
+
if (allowedCmd && ev.tool === 'Bash' && (ev.input as any)?.command === allowedCmd) taskBashToolUseIds.add(ev.toolUseId);
|
|
220
230
|
} else if (ev.type === 'thinking_delta') {
|
|
221
231
|
producedThinking = true;
|
|
222
232
|
} else if (ev.type === 'tool_result') {
|
|
223
233
|
assistantBlocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
|
|
234
|
+
if (ev.isError && taskBashToolUseIds.has(ev.toolUseId)) bashFailure = ev.output;
|
|
224
235
|
} else if (ev.type === 'done') {
|
|
225
236
|
break;
|
|
226
237
|
} else if (ev.type === 'error') {
|
|
@@ -238,6 +249,12 @@ export async function runSchedule(
|
|
|
238
249
|
}
|
|
239
250
|
}
|
|
240
251
|
|
|
252
|
+
// The agent turn can succeed while the command it was asked to run failed — that's the run failing.
|
|
253
|
+
if (status === 'ok' && bashFailure) {
|
|
254
|
+
status = 'error';
|
|
255
|
+
error = `Scheduled command failed:\n${bashFailure.slice(0, 4000)}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
241
258
|
if (status !== 'error') break;
|
|
242
259
|
|
|
243
260
|
// The side-effect boundary. This is NOT an exact `ttft=-1` test — the engine emits events we
|