klyro 1.0.0 → 1.0.2
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/dist/agent/anthropic-adapter.d.ts +49 -5
- package/dist/agent/anthropic-adapter.js +86 -17
- package/dist/agent/capabilities.d.ts +23 -0
- package/dist/agent/capabilities.js +53 -6
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +124 -6
- package/dist/agent/orchestrator.js +425 -58
- package/dist/agent/provider-adapter.d.ts +8 -0
- package/dist/agent/provider-adapter.js +12 -3
- package/dist/agent/retry.d.ts +1 -1
- package/dist/agent/retry.js +54 -12
- package/dist/agent/runtime.d.ts +84 -8
- package/dist/agent/runtime.js +352 -39
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +56 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +54 -3
- package/dist/cli/config.js +146 -3
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/repl.js +196 -29
- package/dist/cli/run.d.ts +13 -11
- package/dist/cli/run.js +144 -20
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.js +4 -1
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/events/catalog.d.ts +37 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +177 -8
- package/dist/mcp/client.d.ts +6 -4
- package/dist/mcp/client.js +83 -14
- package/dist/mcp/config.d.ts +10 -0
- package/dist/mcp/config.js +18 -1
- package/dist/mcp/registry.d.ts +23 -19
- package/dist/mcp/registry.js +127 -8
- package/dist/mcp/schema.d.ts +11 -4
- package/dist/mcp/schema.js +27 -16
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +17 -0
- package/dist/policy/engine.js +162 -9
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +23 -0
- package/dist/providers/model-info.js +43 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.js +5 -5
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +77 -1
- package/dist/tools/fs/edit-file.js +69 -1
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +70 -1
- package/dist/tools/fs/write-file.js +83 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/registry.js +6 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +29 -0
- package/dist/tools/shell/shell-exec.js +170 -12
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +6 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +20 -0
- package/dist/trace/writer.js +62 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +27 -15
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +28 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +44 -0
- package/dist/verification/scoped.js +64 -11
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as path from 'node:path';
|
|
13
13
|
import * as fs from 'node:fs/promises';
|
|
14
|
+
import * as fsSync from 'node:fs';
|
|
14
15
|
import { TOOL_ERROR_CODES } from '../tools/normalize.js';
|
|
15
16
|
export class PathGuardError extends Error {
|
|
16
17
|
code;
|
|
@@ -96,3 +97,48 @@ export async function resolveAndFollowSymlinks(cwd, requested) {
|
|
|
96
97
|
}
|
|
97
98
|
return { resolved: real };
|
|
98
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Symlink-swap guard: refuse when the FINAL path itself is a symlink.
|
|
102
|
+
*
|
|
103
|
+
* File tools resolve symlinks up front, but a racing swap between that
|
|
104
|
+
* check and the final write/rename could redirect an open-with-O_TRUNC /
|
|
105
|
+
* O_CREAT (or rename) through a freshly-planted symlink. Calling this
|
|
106
|
+
* immediately before the final write/rename shrinks that window to ~0:
|
|
107
|
+
* `lstat` never follows the final component, so a swapped-in link is
|
|
108
|
+
* caught. (POSIX `rename(2)` onto a symlink replaces the LINK itself, so
|
|
109
|
+
* the vulnerable op is opening/truncating through the link — which this
|
|
110
|
+
* denies first.) Supported on POSIX and Windows (both implement lstat).
|
|
111
|
+
* Missing paths (ENOENT) are fine — a nonexistent path is not a symlink.
|
|
112
|
+
*/
|
|
113
|
+
export async function assertNotSymlink(p) {
|
|
114
|
+
let st;
|
|
115
|
+
try {
|
|
116
|
+
st = await fs.lstat(p);
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
if (e?.code === 'ENOENT')
|
|
120
|
+
return;
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
if (st.isSymbolicLink()) {
|
|
124
|
+
throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Refusing to write through symlink: ${p}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Open a file for writing without following a trailing symlink (POSIX).
|
|
129
|
+
*
|
|
130
|
+
* Uses O_WRONLY | O_CREAT | O_NOFOLLOW so the open itself fails (ELOOP)
|
|
131
|
+
* when the final component is a symlink, instead of truncating through
|
|
132
|
+
* it. On Windows there is no O_NOFOLLOW, so this falls back to a plain
|
|
133
|
+
* `'w'` open — symlink-swap protection there comes from
|
|
134
|
+
* `assertNotSymlink` (lstat) immediately before the write/rename instead.
|
|
135
|
+
*/
|
|
136
|
+
export function openNoFollowForWrite(dir, file) {
|
|
137
|
+
const target = path.resolve(dir, file);
|
|
138
|
+
if (process.platform === 'win32') {
|
|
139
|
+
// Windows fallback: no O_NOFOLLOW — plain open; see comment above.
|
|
140
|
+
return fsSync.openSync(target, 'w');
|
|
141
|
+
}
|
|
142
|
+
const flags = fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_NOFOLLOW;
|
|
143
|
+
return fsSync.openSync(target, flags, 0o666);
|
|
144
|
+
}
|
|
@@ -24,6 +24,10 @@ const PATTERNS = [
|
|
|
24
24
|
{ name: 'api-key', re: /(?:api[_-]?key|apikey)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{16,}['"]?/gi },
|
|
25
25
|
{ name: 'password', re: /(?:password|passwd|pwd)\s*[:=]\s*['"]?[^\s'"]{4,}['"]?/gi },
|
|
26
26
|
{ name: 'secret-generic', re: /(?:secret|token)\s*[:=]\s*['"]?[A-Za-z0-9_\-+/=]{16,}['"]?/gi },
|
|
27
|
+
{ name: 'discord-token', re: /mfa\.[\w-]{84}|[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}/g },
|
|
28
|
+
{ name: 'npm-token', re: /\bnpm_[A-Za-z0-9]{24,}/g },
|
|
29
|
+
{ name: 'sendgrid-key', re: /\bSG\.[\w-]{22}\.[\w-]{43}/g },
|
|
30
|
+
{ name: 'pypi-token', re: /\bpypi-[A-Za-z0-9_-]{30,}/g },
|
|
27
31
|
];
|
|
28
32
|
const REPLACEMENT = '[REDACTED]';
|
|
29
33
|
/** Redact a single string (or Buffer). */
|
|
@@ -15,4 +15,27 @@ export interface ModelInfo {
|
|
|
15
15
|
}
|
|
16
16
|
export declare const MODEL_REGISTRY: Record<string, ModelInfo>;
|
|
17
17
|
export declare function getModelInfo(id: string): ModelInfo;
|
|
18
|
+
/**
|
|
19
|
+
* Single-source per-1K-token rate table, derived from MODEL_REGISTRY so
|
|
20
|
+
* prices live in exactly one place. Local/self-hosted entries are $0.
|
|
21
|
+
*/
|
|
22
|
+
export declare const MODEL_RATES_PER_1K: Record<string, {
|
|
23
|
+
input: number;
|
|
24
|
+
output: number;
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve per-1K rates for any model id. Exact registry hits win, then the
|
|
28
|
+
* local-model check ($0), then hosted-family fallbacks. Unknown models are
|
|
29
|
+
* $0 — never invent nonzero prices for models we don't recognize.
|
|
30
|
+
*/
|
|
31
|
+
export declare function ratesFor(modelId: string): {
|
|
32
|
+
input: number;
|
|
33
|
+
output: number;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* True for Anthropic-family model ids (Claude). Used by the runtime's
|
|
37
|
+
* cache-aware cost math: only Anthropic bills cacheRead/cacheWrite, so
|
|
38
|
+
* other families keep ignoring those counters.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isAnthropicModel(modelId: string): boolean;
|
|
18
41
|
export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number): number;
|
|
@@ -12,7 +12,48 @@ export const MODEL_REGISTRY = {
|
|
|
12
12
|
export function getModelInfo(id) {
|
|
13
13
|
return MODEL_REGISTRY[id] ?? { id, contextWindow: 100000, maxOutput: 4096, inputPricePer1k: 0, outputPricePer1k: 0, capabilities: { tools: true } };
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Single-source per-1K-token rate table, derived from MODEL_REGISTRY so
|
|
17
|
+
* prices live in exactly one place. Local/self-hosted entries are $0.
|
|
18
|
+
*/
|
|
19
|
+
export const MODEL_RATES_PER_1K = Object.fromEntries(Object.entries(MODEL_REGISTRY).map(([k, v]) => [k, { input: v.inputPricePer1k, output: v.outputPricePer1k }]));
|
|
20
|
+
/** Hosted-family fallback rates for model ids not in the registry. */
|
|
21
|
+
const FAMILY_RATES = [
|
|
22
|
+
{ test: (m) => /o1/i.test(m), input: 0.015, output: 0.06 },
|
|
23
|
+
{ test: (m) => /gpt-3\.5/i.test(m), input: 0.0005, output: 0.0015 },
|
|
24
|
+
{ test: (m) => /gpt-4/i.test(m), input: 0.003, output: 0.015 },
|
|
25
|
+
{ test: (m) => /claude|anthropic/i.test(m), input: 0.003, output: 0.015 },
|
|
26
|
+
{ test: (m) => /gemini/i.test(m), input: 0.00075, output: 0.003 },
|
|
27
|
+
];
|
|
28
|
+
/** Local/self-hosted model ids are always priced at $0. */
|
|
29
|
+
const LOCAL_MODEL_PATTERN = /local|ollama|llama|mistral|mixtral|qwen|gemma|phi-|vllm|llama\.cpp|^mock/i;
|
|
30
|
+
/**
|
|
31
|
+
* Resolve per-1K rates for any model id. Exact registry hits win, then the
|
|
32
|
+
* local-model check ($0), then hosted-family fallbacks. Unknown models are
|
|
33
|
+
* $0 — never invent nonzero prices for models we don't recognize.
|
|
34
|
+
*/
|
|
35
|
+
export function ratesFor(modelId) {
|
|
36
|
+
const exact = MODEL_RATES_PER_1K[modelId];
|
|
37
|
+
if (exact)
|
|
38
|
+
return exact;
|
|
39
|
+
if (LOCAL_MODEL_PATTERN.test(modelId))
|
|
40
|
+
return { input: 0, output: 0 };
|
|
41
|
+
const fam = FAMILY_RATES.find((r) => r.test(modelId));
|
|
42
|
+
if (fam)
|
|
43
|
+
return { input: fam.input, output: fam.output };
|
|
44
|
+
return { input: 0, output: 0 };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* True for Anthropic-family model ids (Claude). Used by the runtime's
|
|
48
|
+
* cache-aware cost math: only Anthropic bills cacheRead/cacheWrite, so
|
|
49
|
+
* other families keep ignoring those counters.
|
|
50
|
+
*/
|
|
51
|
+
export function isAnthropicModel(modelId) {
|
|
52
|
+
return /anthropic|claude/i.test(modelId);
|
|
53
|
+
}
|
|
15
54
|
export function estimateCost(modelId, inputTokens, outputTokens) {
|
|
16
|
-
|
|
17
|
-
|
|
55
|
+
// Single source: delegate to ratesFor so registry, family fallbacks, and
|
|
56
|
+
// local-$0 rules live in exactly one place (no split-brain with getModelInfo).
|
|
57
|
+
const { input, output } = ratesFor(modelId);
|
|
58
|
+
return (inputTokens / 1000) * input + (outputTokens / 1000) * output;
|
|
18
59
|
}
|
package/dist/repl.d.ts
CHANGED
|
@@ -6,4 +6,10 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Type ':quit' (or Ctrl-C / Ctrl-D) to exit.
|
|
8
8
|
*/
|
|
9
|
+
export interface Turn {
|
|
10
|
+
role: 'user' | 'assistant';
|
|
11
|
+
content: string;
|
|
12
|
+
}
|
|
9
13
|
export declare function repl(system: string): Promise<void>;
|
|
14
|
+
/** Drop oldest turns to stay under the size cap. Keep user/assistant pairs together. */
|
|
15
|
+
export declare function trimHistory(history: Turn[]): void;
|
package/dist/repl.js
CHANGED
|
@@ -10,6 +10,7 @@ import * as readline from 'node:readline/promises';
|
|
|
10
10
|
import { stdin, exit } from 'node:process';
|
|
11
11
|
import { streamToStdout, readBoundedText } from './chat.js';
|
|
12
12
|
import { resolveProvider, providerHelp } from './providers.js';
|
|
13
|
+
import { estimateTokens } from './context/tokenizer.js';
|
|
13
14
|
/** Cap conversation history so we don't blow the model's context window. */
|
|
14
15
|
const MAX_HISTORY_TURNS = 40;
|
|
15
16
|
/** Approximate char budget for the user side of history. */
|
|
@@ -26,6 +27,8 @@ export async function repl(system) {
|
|
|
26
27
|
exit(2);
|
|
27
28
|
}
|
|
28
29
|
const { baseURL, apiKey, model, source } = provider;
|
|
30
|
+
// Legacy REPL — prefer `klyro tui`; history truncation is approximate.
|
|
31
|
+
process.stderr.write('klyro: legacy REPL — prefer `klyro tui`; history truncation is approximate.\n');
|
|
29
32
|
const history = [];
|
|
30
33
|
// Adapt readline to the environment. On a TTY we let readline manage the
|
|
31
34
|
// prompt and echo so the user sees their typing; on a pipe we suppress both
|
|
@@ -94,7 +97,7 @@ export async function repl(system) {
|
|
|
94
97
|
}
|
|
95
98
|
}
|
|
96
99
|
/** Drop oldest turns to stay under the size cap. Keep user/assistant pairs together. */
|
|
97
|
-
function trimHistory(history) {
|
|
100
|
+
export function trimHistory(history) {
|
|
98
101
|
// Keep even number of turns (pairs) when trimming for turn count
|
|
99
102
|
while (history.length > MAX_HISTORY_TURNS) {
|
|
100
103
|
// Drop oldest 2 (user+assistant) to preserve adjacency
|
|
@@ -106,21 +109,23 @@ function trimHistory(history) {
|
|
|
106
109
|
history.shift();
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
// Token-budgeted trim (approximate): estimate over the joined history
|
|
113
|
+
// instead of raw chars so long-token text (CJK, code) trims correctly.
|
|
114
|
+
const budgetTokens = Math.ceil(MAX_HISTORY_CHARS / 4);
|
|
115
|
+
if (estimateTokens(history.map((t) => t.content).join('\n')) <= budgetTokens)
|
|
116
|
+
return;
|
|
117
|
+
while (history.length > 2) {
|
|
113
118
|
// Drop oldest pair to avoid orphaning tool context
|
|
114
119
|
const first = history[0];
|
|
115
120
|
const second = history[1];
|
|
116
121
|
if (!first)
|
|
117
122
|
break;
|
|
118
|
-
total -= first.content.length;
|
|
119
123
|
history.shift();
|
|
120
124
|
if (second) {
|
|
121
|
-
total -= second.content.length;
|
|
122
125
|
history.shift();
|
|
123
126
|
}
|
|
127
|
+
if (estimateTokens(history.map((t) => t.content).join('\n')) <= budgetTokens)
|
|
128
|
+
break;
|
|
124
129
|
}
|
|
125
130
|
}
|
|
126
131
|
/** Send the full history, stream the reply, return the assistant text. */
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* P1.1 — spawn_agent tool (6-10fix.md §5).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Async: starts the child worker and returns IMMEDIATELY with
|
|
5
|
+
* `{ taskId, status: 'running', ... }` — never the child transcript.
|
|
6
|
+
* Await completion with `task_wait`, poll with `task_get` / `task_list`,
|
|
7
|
+
* merge the child's worktree with `task_apply`, or abort with `task_stop`.
|
|
8
8
|
*/
|
|
9
9
|
import { z } from 'zod';
|
|
10
10
|
import { defineTool } from '../types.js';
|
|
@@ -18,7 +18,7 @@ const InputSchema = z.object({
|
|
|
18
18
|
});
|
|
19
19
|
export const spawnAgentTool = defineTool({
|
|
20
20
|
name: 'spawn_agent',
|
|
21
|
-
description: 'Spawn a child agent
|
|
21
|
+
description: 'Spawn a child agent asynchronously; returns immediately with { taskId, status: "running", ... }. Child transcript stays separate — await it with task_wait, poll with task_get, merge with task_apply, abort with task_stop.',
|
|
22
22
|
inputSchema: InputSchema,
|
|
23
23
|
permission: 'admin',
|
|
24
24
|
isConcurrencySafe: false,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task_apply tool — merge a succeeded child's work into the parent tree.
|
|
3
|
+
*
|
|
4
|
+
* For worktree-isolated children this merges the `klyro/<taskId>` branch and
|
|
5
|
+
* emits `subtask.merged`; for shared-cwd children (whose edits already
|
|
6
|
+
* landed in the parent tree) it is the explicit acknowledgement step that
|
|
7
|
+
* emits `subtask.merged` and returns the summary. Non-succeeded tasks error
|
|
8
|
+
* with NOT_READY; conflicts error with MERGE_CONFLICT.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { defineTool } from '../types.js';
|
|
12
|
+
import { toToolError } from '../normalize.js';
|
|
13
|
+
const InputSchema = z.object({
|
|
14
|
+
taskId: z.string().min(1).describe('Succeeded task id to apply (e.g. task_1)'),
|
|
15
|
+
});
|
|
16
|
+
export const taskApplyTool = defineTool({
|
|
17
|
+
name: 'task_apply',
|
|
18
|
+
description: 'Merge a succeeded child task into the parent tree (emits subtask.merged). Errors with NOT_READY unless the task succeeded.',
|
|
19
|
+
inputSchema: InputSchema,
|
|
20
|
+
permission: 'admin',
|
|
21
|
+
isConcurrencySafe: false,
|
|
22
|
+
renderCall: (input) => `task_apply(${input.taskId})`,
|
|
23
|
+
execute: async (input, ctx) => {
|
|
24
|
+
try {
|
|
25
|
+
const bridge = ctx.agentBridge;
|
|
26
|
+
if (!bridge) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: { code: 'NO_ORCHESTRATOR', message: 'task_apply requires an active orchestrator' },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (!bridge.applyTask) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: { code: 'UNSUPPORTED', message: 'task_apply is not supported by this orchestrator bridge' },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return await bridge.applyTask(input.taskId);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return { ok: false, error: toToolError(err) };
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task_stop tool — cancel a running child task via `taskManager.cancel`.
|
|
3
|
+
* Idempotent on terminal tasks (returns the current status).
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { defineTool } from '../types.js';
|
|
7
|
+
import { toToolError } from '../normalize.js';
|
|
8
|
+
const InputSchema = z.object({
|
|
9
|
+
taskId: z.string().min(1).describe('Task id to stop (e.g. task_1)'),
|
|
10
|
+
});
|
|
11
|
+
export const taskStopTool = defineTool({
|
|
12
|
+
name: 'task_stop',
|
|
13
|
+
description: 'Cancel a running child-agent task. Returns { taskId, status }. Idempotent on terminal tasks.',
|
|
14
|
+
inputSchema: InputSchema,
|
|
15
|
+
permission: 'admin',
|
|
16
|
+
isConcurrencySafe: false,
|
|
17
|
+
renderCall: (input) => `task_stop(${input.taskId})`,
|
|
18
|
+
execute: async (input, ctx) => {
|
|
19
|
+
try {
|
|
20
|
+
const bridge = ctx.agentBridge;
|
|
21
|
+
if (!bridge) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
error: { code: 'NO_ORCHESTRATOR', message: 'task_stop requires an active orchestrator' },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (!bridge.cancelTask) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
error: { code: 'UNSUPPORTED', message: 'task_stop is not supported by this orchestrator bridge' },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return bridge.cancelTask(input.taskId);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
return { ok: false, error: toToolError(err) };
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ChildSummary } from '../../agent/orchestrator.js';
|
|
2
|
+
import type { TaskStatus } from '../../agent/task-manager.js';
|
|
3
|
+
export interface WaitTaskEntry {
|
|
4
|
+
taskId: string;
|
|
5
|
+
status: TaskStatus;
|
|
6
|
+
summary?: ChildSummary;
|
|
7
|
+
error?: {
|
|
8
|
+
code: string;
|
|
9
|
+
message: string;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export declare const taskWaitTool: import("../types.js").Tool<{
|
|
13
|
+
taskIds: string[];
|
|
14
|
+
timeoutMs?: number | undefined;
|
|
15
|
+
}, {
|
|
16
|
+
tasks: import("../../agent/orchestrator.js").WaitedTask[];
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task_wait tool — await one or more child tasks without cancelling them.
|
|
3
|
+
*
|
|
4
|
+
* Delegates to `AgentSpawnBridge.waitForTasks` when available, otherwise
|
|
5
|
+
* polls `getTask` until every id is terminal or `timeoutMs` elapses.
|
|
6
|
+
* Entries that do not settle in time come back with status `'running'` —
|
|
7
|
+
* the underlying task keeps running. Documented contract: waiting never
|
|
8
|
+
* cancels.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { defineTool } from '../types.js';
|
|
12
|
+
import { toToolError } from '../normalize.js';
|
|
13
|
+
const TERMINAL = new Set([
|
|
14
|
+
'succeeded',
|
|
15
|
+
'failed',
|
|
16
|
+
'cancelled',
|
|
17
|
+
'timed_out',
|
|
18
|
+
'blocked',
|
|
19
|
+
]);
|
|
20
|
+
const InputSchema = z.object({
|
|
21
|
+
taskIds: z.array(z.string().min(1)).min(1).describe('Task ids to await (e.g. ["task_1"])'),
|
|
22
|
+
timeoutMs: z.number().int().positive().optional().describe('Per-task wait timeout in ms (unset = wait indefinitely)'),
|
|
23
|
+
});
|
|
24
|
+
export const taskWaitTool = defineTool({
|
|
25
|
+
name: 'task_wait',
|
|
26
|
+
description: 'Await child-agent tasks until they settle. Entries that time out return status "running" — the task is NOT cancelled. Never cancels.',
|
|
27
|
+
inputSchema: InputSchema,
|
|
28
|
+
permission: 'read',
|
|
29
|
+
isConcurrencySafe: true,
|
|
30
|
+
renderCall: (input) => `task_wait(${input.taskIds.join(', ')})`,
|
|
31
|
+
execute: async (input, ctx) => {
|
|
32
|
+
try {
|
|
33
|
+
const bridge = ctx.agentBridge;
|
|
34
|
+
if (!bridge) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
error: { code: 'NO_ORCHESTRATOR', message: 'task_wait requires an active orchestrator' },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (bridge.waitForTasks) {
|
|
41
|
+
const r = await bridge.waitForTasks(input.taskIds, input.timeoutMs);
|
|
42
|
+
return { ok: true, value: r };
|
|
43
|
+
}
|
|
44
|
+
// Fallback for bridges without waitForTasks: poll getTask snapshots.
|
|
45
|
+
const deadline = input.timeoutMs !== undefined ? Date.now() + input.timeoutMs : undefined;
|
|
46
|
+
const tasks = [];
|
|
47
|
+
for (const taskId of input.taskIds) {
|
|
48
|
+
let snap = bridge.getTask(taskId);
|
|
49
|
+
if (!snap) {
|
|
50
|
+
tasks.push({ taskId, status: 'failed', error: { code: 'NOT_FOUND', message: `Unknown task: ${taskId}` } });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
while (!TERMINAL.has(snap.status) && (deadline === undefined || Date.now() < deadline)) {
|
|
54
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
55
|
+
const next = bridge.getTask(taskId);
|
|
56
|
+
if (next)
|
|
57
|
+
snap = next;
|
|
58
|
+
}
|
|
59
|
+
tasks.push({
|
|
60
|
+
taskId,
|
|
61
|
+
status: snap.status,
|
|
62
|
+
summary: {
|
|
63
|
+
taskId: snap.id,
|
|
64
|
+
agentName: snap.agentName,
|
|
65
|
+
status: snap.status,
|
|
66
|
+
durationMs: snap.durationMs ?? 0,
|
|
67
|
+
changedFiles: [],
|
|
68
|
+
...(snap.error ? { error: { code: snap.error.code, message: snap.error.message } } : {}),
|
|
69
|
+
},
|
|
70
|
+
...(snap.error ? { error: { code: snap.error.code, message: snap.error.message } } : {}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, value: { tasks } };
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
return { ok: false, error: toToolError(err) };
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
});
|
|
@@ -20,9 +20,43 @@ import * as fs from 'node:fs/promises';
|
|
|
20
20
|
import * as path from 'node:path';
|
|
21
21
|
import { z } from 'zod';
|
|
22
22
|
import { defineTool } from '../types.js';
|
|
23
|
-
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
23
|
+
import { resolveAndFollowSymlinks, assertNotSymlink } from '../../policy/path-guard.js';
|
|
24
24
|
import { safe } from '../normalize.js';
|
|
25
25
|
import { wasRead } from './read-history.js';
|
|
26
|
+
// Extension-anchored: matches test.ts, foo.test.ts, foo-test.ts, foo.spec.js,
|
|
27
|
+
// __tests__ segments — but NOT latest.ts / attest.ts / contest-data.
|
|
28
|
+
const TEST_BASENAME_RE = /(^|[._-])(test|spec)\.[^.]+$|\.test\.[^.]+$|\.spec\.[^.]+$|__(tests|snapshots)__/i;
|
|
29
|
+
function repairGuardHit(targetPath, _content) {
|
|
30
|
+
const base = targetPath.split(/[\\/]/).pop() ?? targetPath;
|
|
31
|
+
// Basename alone decides. Patch CONTENT is never consulted for non-test
|
|
32
|
+
// paths (the old assert-skip regex over-blocked ordinary sources
|
|
33
|
+
// containing e.g. `it(` or `test(`), and stays unconsulted for test-like
|
|
34
|
+
// paths too since the basename already denies.
|
|
35
|
+
return TEST_BASENAME_RE.test(base);
|
|
36
|
+
}
|
|
37
|
+
async function allowedHit(cwd, resolved, allowed) {
|
|
38
|
+
if (!allowed)
|
|
39
|
+
return false;
|
|
40
|
+
// Canonicalize both sides (realpath) so lexical-vs-canonical spellings
|
|
41
|
+
// of the same directory (e.g. Windows 8.3 short names) compare equal.
|
|
42
|
+
const canon = async (p) => {
|
|
43
|
+
try {
|
|
44
|
+
return await fs.realpath(p);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return path.resolve(p);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const canonTarget = await canon(resolved);
|
|
51
|
+
for (const base of allowed) {
|
|
52
|
+
const absBase = path.isAbsolute(base) ? path.resolve(base) : path.resolve(cwd, base);
|
|
53
|
+
const canonBase = await canon(absBase);
|
|
54
|
+
const rel = path.relative(canonBase, canonTarget);
|
|
55
|
+
if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
26
60
|
const InputSchema = z.object({
|
|
27
61
|
patch: z.string().min(1).describe('Unified diff patch text'),
|
|
28
62
|
});
|
|
@@ -116,6 +150,28 @@ async function guardWrite(cwd, relPath, resolved) {
|
|
|
116
150
|
// missing file → creation path, no guard needed
|
|
117
151
|
}
|
|
118
152
|
}
|
|
153
|
+
// TOCTOU shrink (mirrors write_file): re-resolve symlinks immediately
|
|
154
|
+
// before the write and refuse if the target moved. Compares canonicalized
|
|
155
|
+
// forms (not raw strings) to avoid false positives from
|
|
156
|
+
// lexical-vs-canonical spellings (e.g. `sub/../a.txt`, short names).
|
|
157
|
+
async function assertSameTarget(cwd, relPath, resolved) {
|
|
158
|
+
const { resolved: reResolved } = await resolveAndFollowSymlinks(cwd, relPath);
|
|
159
|
+
const canon = async (p) => {
|
|
160
|
+
try {
|
|
161
|
+
return await fs.realpath(p);
|
|
162
|
+
}
|
|
163
|
+
catch { /* missing file → try parent */ }
|
|
164
|
+
try {
|
|
165
|
+
return path.join(await fs.realpath(path.dirname(p)), path.basename(p));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return p;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
if ((await canon(reResolved)) !== (await canon(resolved))) {
|
|
172
|
+
throw Object.assign(new Error(`Path target changed between check and write: ${relPath} (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
119
175
|
export const applyPatchTool = defineTool({
|
|
120
176
|
name: 'apply_patch',
|
|
121
177
|
description: 'Apply a unified diff patch (Codex-style). Real hunk application with context matching; mismatches fail loudly.',
|
|
@@ -130,6 +186,13 @@ export const applyPatchTool = defineTool({
|
|
|
130
186
|
for (const line of lines) {
|
|
131
187
|
if (line.startsWith('*** Begin Patch') || line.startsWith('*** End Patch'))
|
|
132
188
|
continue;
|
|
189
|
+
// Delete sections are NOT supported — reject loudly rather than
|
|
190
|
+
// mis-parsing the section as a write.
|
|
191
|
+
if (line.startsWith('*** Delete File:')) {
|
|
192
|
+
throw Object.assign(new Error(`Delete sections not supported: ${line.trim()} (INVALID_PATCH)`), {
|
|
193
|
+
code: 'INVALID_PATCH',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
133
196
|
if (line.startsWith('*** Update File:')) {
|
|
134
197
|
cur = { op: 'update', path: line.replace('*** Update File:', '').trim(), body: [] };
|
|
135
198
|
sections.push(cur);
|
|
@@ -150,7 +213,13 @@ export const applyPatchTool = defineTool({
|
|
|
150
213
|
for (const sec of sections) {
|
|
151
214
|
if (!sec.path)
|
|
152
215
|
throw Object.assign(new Error('Patch section missing file path'), { code: 'INVALID_PATCH' });
|
|
216
|
+
if (ctx.repairGuard?.denyTestEdits && repairGuardHit(sec.path, sec.body.join('\n'))) {
|
|
217
|
+
throw Object.assign(new Error('repair-guard: test edits denied (denyTestEdits)'), { code: 'POLICY_DENIED' });
|
|
218
|
+
}
|
|
153
219
|
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, sec.path);
|
|
220
|
+
if (await allowedHit(ctx.cwd, resolved, ctx.agentAllowedPaths)) {
|
|
221
|
+
throw Object.assign(new Error(`agent path not allowed: ${resolved} (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
|
|
222
|
+
}
|
|
154
223
|
const hunks = parseHunks(sec.body);
|
|
155
224
|
if (sec.op === 'add' || hunks.length === 0) {
|
|
156
225
|
// Creation path: content from + lines (legacy tolerant format).
|
|
@@ -171,7 +240,11 @@ export const applyPatchTool = defineTool({
|
|
|
171
240
|
.map((l) => l.slice(1))
|
|
172
241
|
.join('\n');
|
|
173
242
|
await guardWrite(ctx.cwd, sec.path, resolved);
|
|
243
|
+
await assertSameTarget(ctx.cwd, sec.path, resolved);
|
|
174
244
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
245
|
+
// Symlink-swap guard: refuse if the final dest became a symlink
|
|
246
|
+
// since the up-front resolve (lstat never follows the final link).
|
|
247
|
+
await assertNotSymlink(resolved);
|
|
175
248
|
await fs.writeFile(resolved, content ? content + '\n' : '', 'utf-8');
|
|
176
249
|
patchedFiles.push(sec.path);
|
|
177
250
|
continue;
|
|
@@ -192,7 +265,10 @@ export const applyPatchTool = defineTool({
|
|
|
192
265
|
if (hasTrailingNewline && fileLines[fileLines.length - 1] === '')
|
|
193
266
|
fileLines.pop();
|
|
194
267
|
const next = applyHunks(sec.path, fileLines, hunks);
|
|
268
|
+
await assertSameTarget(ctx.cwd, sec.path, resolved);
|
|
195
269
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
270
|
+
// Symlink-swap guard (mirrors the creation path above).
|
|
271
|
+
await assertNotSymlink(resolved);
|
|
196
272
|
await fs.writeFile(resolved, next.join('\n') + (hasTrailingNewline ? '\n' : ''), 'utf-8');
|
|
197
273
|
patchedFiles.push(sec.path);
|
|
198
274
|
}
|