@tpsdev-ai/agent 0.1.0 → 0.4.1
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/README.md +54 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +32 -0
- package/dist/bin.js.map +1 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +39 -0
- package/dist/config.js.map +1 -0
- package/dist/governance/boundary.d.ts +30 -0
- package/dist/governance/boundary.d.ts.map +1 -0
- package/dist/governance/boundary.js +120 -0
- package/dist/governance/boundary.js.map +1 -0
- package/dist/governance/review-gate.d.ts +9 -0
- package/dist/governance/review-gate.d.ts.map +1 -0
- package/dist/governance/review-gate.js +28 -0
- package/dist/governance/review-gate.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/io/context.d.ts +18 -0
- package/dist/io/context.d.ts.map +1 -0
- package/dist/io/context.js +76 -0
- package/dist/io/context.js.map +1 -0
- package/dist/io/mail.d.ts +22 -0
- package/dist/io/mail.d.ts.map +1 -0
- package/dist/io/mail.js +45 -0
- package/dist/io/mail.js.map +1 -0
- package/dist/io/memory.d.ts +24 -0
- package/dist/io/memory.d.ts.map +1 -0
- package/dist/io/memory.js +91 -0
- package/dist/io/memory.js.map +1 -0
- package/dist/llm/provider.d.ts +26 -0
- package/dist/llm/provider.d.ts.map +1 -0
- package/dist/llm/provider.js +254 -0
- package/dist/llm/provider.js.map +1 -0
- package/dist/runtime/agent.d.ts +14 -0
- package/dist/runtime/agent.d.ts.map +1 -0
- package/dist/runtime/agent.js +46 -0
- package/dist/runtime/agent.js.map +1 -0
- package/dist/runtime/event-loop.d.ts +32 -0
- package/dist/runtime/event-loop.d.ts.map +1 -0
- package/dist/runtime/event-loop.js +178 -0
- package/dist/runtime/event-loop.js.map +1 -0
- package/dist/runtime/types.d.ts +66 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/runtime/types.js +2 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/tools/edit.d.ts +4 -0
- package/dist/tools/edit.d.ts.map +1 -0
- package/dist/tools/edit.js +46 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/exec.d.ts +4 -0
- package/dist/tools/exec.d.ts.map +1 -0
- package/dist/tools/exec.js +74 -0
- package/dist/tools/exec.js.map +1 -0
- package/dist/tools/index.d.ts +17 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/mail.d.ts +4 -0
- package/dist/tools/mail.d.ts.map +1 -0
- package/dist/tools/mail.js +19 -0
- package/dist/tools/mail.js.map +1 -0
- package/dist/tools/read.d.ts +4 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +31 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/registry.d.ts +24 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +23 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/write.d.ts +4 -0
- package/dist/tools/write.d.ts.map +1 -0
- package/dist/tools/write.js +32 -0
- package/dist/tools/write.js.map +1 -0
- package/package.json +19 -4
- package/src/bin.ts +40 -0
- package/src/config.ts +64 -0
- package/src/governance/boundary.ts +112 -18
- package/src/index.ts +15 -2
- package/src/io/context.ts +59 -15
- package/src/io/memory.ts +53 -9
- package/src/llm/provider.ts +203 -42
- package/src/runtime/agent.ts +30 -3
- package/src/runtime/event-loop.ts +168 -26
- package/src/runtime/types.ts +54 -6
- package/src/tools/edit.ts +59 -0
- package/src/tools/exec.ts +92 -0
- package/src/tools/index.ts +30 -0
- package/src/tools/mail.ts +28 -0
- package/src/tools/read.ts +38 -0
- package/src/tools/registry.ts +16 -7
- package/src/tools/write.ts +40 -0
- package/src/types/js-tiktoken.d.ts +7 -0
- package/test/governance.test.ts +61 -32
- package/test/io.test.ts +15 -7
- package/test/runtime.test.ts +26 -5
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { BoundaryManager } from "../governance/boundary.js";
|
|
3
|
+
import type { Tool } from "./registry.js";
|
|
4
|
+
import type { ToolResult } from "../runtime/types.js";
|
|
5
|
+
|
|
6
|
+
interface EditArgs {
|
|
7
|
+
path: string;
|
|
8
|
+
old_string: string;
|
|
9
|
+
new_string: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function makeEditTool(boundary: BoundaryManager): Tool {
|
|
13
|
+
return {
|
|
14
|
+
name: "edit",
|
|
15
|
+
description: "Search-and-replace in one file. Replaces exactly one occurrence.\nInput: {\"path\": string, \"old_string\": string, \"new_string\": string}",
|
|
16
|
+
input_schema: {
|
|
17
|
+
path: { type: "string", description: "Path in workspace" },
|
|
18
|
+
old_string: { type: "string", description: "Text to replace" },
|
|
19
|
+
new_string: { type: "string", description: "Replacement text" },
|
|
20
|
+
},
|
|
21
|
+
async execute(args: Record<string, unknown>): Promise<ToolResult> {
|
|
22
|
+
const payload = (args as unknown) as EditArgs;
|
|
23
|
+
if (
|
|
24
|
+
typeof payload.path !== "string" ||
|
|
25
|
+
typeof payload.old_string !== "string" ||
|
|
26
|
+
typeof payload.new_string !== "string"
|
|
27
|
+
) {
|
|
28
|
+
return { content: "edit tool requires path, old_string, new_string", isError: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const workspacePath = boundary.resolveWorkspacePath(payload.path);
|
|
32
|
+
if (!existsSync(workspacePath) || !BoundaryManager.canFollowSymlink(workspacePath)) {
|
|
33
|
+
if (existsSync(workspacePath)) {
|
|
34
|
+
return { content: `Refusing to follow symlink: ${payload.path}`, isError: true };
|
|
35
|
+
}
|
|
36
|
+
return { content: `edit failed: file not found ${payload.path}`, isError: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const current = readFileSync(workspacePath, "utf-8");
|
|
41
|
+
const first = current.indexOf(payload.old_string);
|
|
42
|
+
const last = current.lastIndexOf(payload.old_string);
|
|
43
|
+
|
|
44
|
+
if (first === -1) {
|
|
45
|
+
return { content: `No occurrence of old_string found in ${payload.path}`, isError: true };
|
|
46
|
+
}
|
|
47
|
+
if (first !== last) {
|
|
48
|
+
return { content: `old_string matches multiple locations in ${payload.path} — provide narrower context`, isError: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const next = current.replace(payload.old_string, payload.new_string);
|
|
52
|
+
writeFileSync(workspacePath, next, "utf-8");
|
|
53
|
+
return { content: `Edited ${payload.path}`, isError: false };
|
|
54
|
+
} catch (err: any) {
|
|
55
|
+
return { content: `edit failed: ${err?.message ?? String(err)}`, isError: true };
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { BoundaryManager } from "../governance/boundary.js";
|
|
3
|
+
import type { Tool } from "./registry.js";
|
|
4
|
+
import type { ToolResult } from "../runtime/types.js";
|
|
5
|
+
|
|
6
|
+
interface ExecArgs {
|
|
7
|
+
command: string;
|
|
8
|
+
args?: string[];
|
|
9
|
+
cwd?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sanitizeOutput(data: unknown): string {
|
|
13
|
+
if (!data) return "";
|
|
14
|
+
return String(data);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function makeExecTool(boundary: BoundaryManager, allowlist: string[] = []): Tool {
|
|
18
|
+
const safeAllow = allowlist.map((c) => c.toLowerCase());
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
name: "exec",
|
|
22
|
+
description: "Execute a command.\nInput: {\"command\": string, \"args\": string[] (optional)",
|
|
23
|
+
input_schema: {
|
|
24
|
+
command: { type: "string", description: "Executable name" },
|
|
25
|
+
args: { type: "array", description: "Arguments to pass" },
|
|
26
|
+
cwd: { type: "string", description: "Optional cwd relative to workspace" },
|
|
27
|
+
},
|
|
28
|
+
async execute(args: Record<string, unknown>): Promise<ToolResult> {
|
|
29
|
+
const payload = (args as unknown) as unknown as ExecArgs;
|
|
30
|
+
if (typeof payload.command !== "string") {
|
|
31
|
+
return { content: "exec tool requires command string", isError: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const command = payload.command;
|
|
35
|
+
const cmdArgs = Array.isArray(payload.args)
|
|
36
|
+
? payload.args.map((entry) => String(entry))
|
|
37
|
+
: [];
|
|
38
|
+
|
|
39
|
+
const normalized = command.toLowerCase();
|
|
40
|
+
if (safeAllow.length > 0 && !safeAllow.includes(normalized)) {
|
|
41
|
+
return { content: `Command not allowed: ${command}`, isError: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
boundary.validateCommand(normalized, cmdArgs);
|
|
46
|
+
} catch (err: any) {
|
|
47
|
+
return { content: `exec blocked: ${err?.message ?? String(err)}`, isError: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cwd = payload.cwd ? boundary.resolveWorkspacePath(payload.cwd) : undefined;
|
|
51
|
+
const env = boundary.scrubEnvironment(["ANTHROPIC_API_KEY", "GOOGLE_API_KEY", "OPENAI_API_KEY", "OLLAMA_HOST", "OLLAMA_API_KEY"]);
|
|
52
|
+
|
|
53
|
+
return new Promise<ToolResult>((resolve) => {
|
|
54
|
+
const child = spawn(normalized, cmdArgs, {
|
|
55
|
+
cwd,
|
|
56
|
+
env,
|
|
57
|
+
shell: false,
|
|
58
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let stdout = "";
|
|
62
|
+
let stderr = "";
|
|
63
|
+
|
|
64
|
+
child.stdout?.on("data", (chunk) => {
|
|
65
|
+
stdout += sanitizeOutput(chunk);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
child.stderr?.on("data", (chunk) => {
|
|
69
|
+
stderr += sanitizeOutput(chunk);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
child.on("error", (err) => {
|
|
73
|
+
resolve({
|
|
74
|
+
content: `exec failed: ${sanitizeOutput(err?.message)}`,
|
|
75
|
+
isError: true,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
child.on("close", (code) => {
|
|
80
|
+
if (code === 0) {
|
|
81
|
+
resolve({ content: stdout || "(no output)", isError: false });
|
|
82
|
+
} else {
|
|
83
|
+
resolve({
|
|
84
|
+
content: `exit=${code}\nstdout:\n${stdout}\nstderr:\n${stderr}`,
|
|
85
|
+
isError: true,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { BoundaryManager } from "../governance/boundary.js";
|
|
2
|
+
import { MailClient } from "../io/mail.js";
|
|
3
|
+
import { ToolRegistry } from "./registry.js";
|
|
4
|
+
import { makeReadTool } from "./read.js";
|
|
5
|
+
import { makeWriteTool } from "./write.js";
|
|
6
|
+
import { makeEditTool } from "./edit.js";
|
|
7
|
+
import { makeExecTool } from "./exec.js";
|
|
8
|
+
import { makeMailTool } from "./mail.js";
|
|
9
|
+
|
|
10
|
+
export { makeReadTool, makeWriteTool, makeEditTool, makeExecTool, makeMailTool };
|
|
11
|
+
|
|
12
|
+
export interface ToolSetOptions {
|
|
13
|
+
boundary: BoundaryManager;
|
|
14
|
+
mail: MailClient;
|
|
15
|
+
tools?: Array<"read" | "write" | "edit" | "exec" | "mail">;
|
|
16
|
+
execAllowlist?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createDefaultToolset({ boundary, mail, tools, execAllowlist }: ToolSetOptions): ToolRegistry {
|
|
20
|
+
const registry = new ToolRegistry();
|
|
21
|
+
const names: Array<"read" | "write" | "edit" | "exec" | "mail"> = tools ?? ["read", "write", "edit", "exec", "mail"];
|
|
22
|
+
|
|
23
|
+
if (names.includes("read")) registry.register(makeReadTool(boundary));
|
|
24
|
+
if (names.includes("write")) registry.register(makeWriteTool(boundary));
|
|
25
|
+
if (names.includes("edit")) registry.register(makeEditTool(boundary));
|
|
26
|
+
if (names.includes("exec")) registry.register(makeExecTool(boundary, execAllowlist));
|
|
27
|
+
if (names.includes("mail")) registry.register(makeMailTool(mail));
|
|
28
|
+
|
|
29
|
+
return registry;
|
|
30
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Tool } from "./registry.js";
|
|
2
|
+
import type { MailClient } from "../io/mail.js";
|
|
3
|
+
import type { ToolResult } from "../runtime/types.js";
|
|
4
|
+
|
|
5
|
+
interface MailArgs {
|
|
6
|
+
to: string;
|
|
7
|
+
body: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function makeMailTool(mail: MailClient): Tool {
|
|
11
|
+
return {
|
|
12
|
+
name: "mail",
|
|
13
|
+
description: "Send mail to another agent or manager.\nInput: {\"to\": string, \"body\": string}",
|
|
14
|
+
input_schema: {
|
|
15
|
+
to: { type: "string", description: "Recipient" },
|
|
16
|
+
body: { type: "string", description: "Message body" },
|
|
17
|
+
},
|
|
18
|
+
async execute(args: Record<string, unknown>): Promise<ToolResult> {
|
|
19
|
+
const payload = (args as unknown) as unknown as MailArgs;
|
|
20
|
+
if (typeof payload.to !== "string" || typeof payload.body !== "string") {
|
|
21
|
+
return { content: "mail tool requires to and body", isError: true };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await mail.sendMail(payload.to, payload.body);
|
|
25
|
+
return { content: `Sent mail to ${payload.to}`, isError: false };
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { BoundaryManager } from "../governance/boundary.js";
|
|
3
|
+
import type { Tool } from "./registry.js";
|
|
4
|
+
import type { ToolResult } from "../runtime/types.js";
|
|
5
|
+
|
|
6
|
+
interface ReadArgs {
|
|
7
|
+
path: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function makeReadTool(boundary: BoundaryManager): Tool {
|
|
11
|
+
return {
|
|
12
|
+
name: "read",
|
|
13
|
+
description: "Read file contents from workspace.\nInput: {\"path\": string}",
|
|
14
|
+
input_schema: {
|
|
15
|
+
path: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Path to read, relative to workspace",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
async execute(args: Record<string, unknown>): Promise<ToolResult> {
|
|
21
|
+
const { path: rawPath } = (args as unknown) as ReadArgs;
|
|
22
|
+
if (typeof rawPath !== "string") {
|
|
23
|
+
return { content: "read tool requires path string", isError: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const workspacePath = boundary.resolveWorkspacePath(rawPath);
|
|
27
|
+
if (existsSync(workspacePath) && !BoundaryManager.canFollowSymlink(workspacePath)) {
|
|
28
|
+
return { content: `Refusing to follow symlink: ${rawPath}`, isError: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
return { content: readFileSync(workspacePath, "utf-8"), isError: false };
|
|
33
|
+
} catch (err: any) {
|
|
34
|
+
return { content: `read failed: ${err?.message ?? String(err)}`, isError: true };
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/tools/registry.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
+
import type { ToolResult } from "../runtime/types.js";
|
|
2
|
+
|
|
1
3
|
export interface Tool {
|
|
2
4
|
name: string;
|
|
3
5
|
description: string;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
input_schema: {
|
|
7
|
+
[key: string]: {
|
|
8
|
+
type: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
};
|
|
11
|
+
[key: number]: never;
|
|
12
|
+
};
|
|
13
|
+
execute(args: Record<string, unknown>): Promise<ToolResult>;
|
|
6
14
|
}
|
|
7
15
|
|
|
8
16
|
/**
|
|
9
17
|
* Central registry for native TPS agent tools.
|
|
10
|
-
* Only tools explicitly registered (and allowed by the nono profile)
|
|
11
|
-
* are exposed to the LLM.
|
|
12
18
|
*/
|
|
13
19
|
export class ToolRegistry {
|
|
14
|
-
private tools = new Map<string, Tool>();
|
|
20
|
+
private readonly tools = new Map<string, Tool>();
|
|
15
21
|
|
|
16
22
|
register(tool: Tool): void {
|
|
17
23
|
this.tools.set(tool.name, tool);
|
|
@@ -25,9 +31,12 @@ export class ToolRegistry {
|
|
|
25
31
|
return Array.from(this.tools.values());
|
|
26
32
|
}
|
|
27
33
|
|
|
28
|
-
async execute(name: string, args: Record<string, unknown>): Promise<
|
|
34
|
+
async execute(name: string, args: Record<string, unknown>): Promise<ToolResult> {
|
|
29
35
|
const tool = this.tools.get(name);
|
|
30
|
-
if (!tool)
|
|
36
|
+
if (!tool) {
|
|
37
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
return tool.execute(args);
|
|
32
41
|
}
|
|
33
42
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { BoundaryManager } from "../governance/boundary.js";
|
|
4
|
+
import type { Tool } from "./registry.js";
|
|
5
|
+
import type { ToolResult } from "../runtime/types.js";
|
|
6
|
+
|
|
7
|
+
interface WriteArgs {
|
|
8
|
+
path: string;
|
|
9
|
+
content: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function makeWriteTool(boundary: BoundaryManager): Tool {
|
|
13
|
+
return {
|
|
14
|
+
name: "write",
|
|
15
|
+
description: "Create or overwrite a file.\nInput: {\"path\": string, \"content\": string}",
|
|
16
|
+
input_schema: {
|
|
17
|
+
path: { type: "string", description: "Path to write, relative to workspace" },
|
|
18
|
+
content: { type: "string", description: "File contents" },
|
|
19
|
+
},
|
|
20
|
+
async execute(args: Record<string, unknown>): Promise<ToolResult> {
|
|
21
|
+
const payload = (args as unknown) as WriteArgs;
|
|
22
|
+
if (typeof payload.path !== "string" || typeof payload.content !== "string") {
|
|
23
|
+
return { content: "write tool requires path and content strings", isError: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const workspacePath = boundary.resolveWorkspacePath(payload.path);
|
|
27
|
+
if (existsSync(workspacePath) && !BoundaryManager.canFollowSymlink(workspacePath)) {
|
|
28
|
+
return { content: `Refusing to follow symlink: ${payload.path}`, isError: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(dirname(workspacePath), { recursive: true });
|
|
33
|
+
writeFileSync(workspacePath, payload.content, "utf-8");
|
|
34
|
+
return { content: `Wrote ${payload.path}`, isError: false };
|
|
35
|
+
} catch (err: any) {
|
|
36
|
+
return { content: `write failed: ${err?.message ?? String(err)}`, isError: true };
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
declare module "js-tiktoken" {
|
|
2
|
+
export function encodingForModel(model: string): { encode: (text: string) => number[] };
|
|
3
|
+
export function encoding_for_model(model: string): { encode: (text: string) => number[] };
|
|
4
|
+
export function getEncoding(name: string): { encode: (text: string) => number[] };
|
|
5
|
+
const _default: any;
|
|
6
|
+
export default _default;
|
|
7
|
+
}
|
package/test/governance.test.ts
CHANGED
|
@@ -1,40 +1,42 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
5
6
|
import { BoundaryManager } from "../src/governance/boundary.js";
|
|
6
7
|
import { ReviewGate } from "../src/governance/review-gate.js";
|
|
7
8
|
import { MailClient } from "../src/io/mail.js";
|
|
8
9
|
import { ToolRegistry } from "../src/tools/registry.js";
|
|
10
|
+
import { makeReadTool, makeWriteTool, makeEditTool } from "../src/tools/index.js";
|
|
9
11
|
|
|
10
12
|
describe("BoundaryManager", () => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
let workspace: string;
|
|
14
|
+
let boundary: BoundaryManager;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
workspace = mkdtempSync(join(tmpdir(), "tps-boundary-"));
|
|
18
|
+
boundary = new BoundaryManager(workspace);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
rmSync(workspace, { recursive: true, force: true });
|
|
16
23
|
});
|
|
17
24
|
|
|
18
|
-
test("
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
expect(
|
|
25
|
+
test("allows registered network host", () => {
|
|
26
|
+
boundary.addNetworkHost("api.openai.com");
|
|
27
|
+
expect(boundary.isNetworkAllowed("api.openai.com")).toBe(true);
|
|
28
|
+
expect(boundary.isNetworkAllowed("evil.com")).toBe(false);
|
|
22
29
|
});
|
|
23
30
|
|
|
24
|
-
test("
|
|
25
|
-
|
|
26
|
-
mgr.addPath("/workspace");
|
|
27
|
-
expect(mgr.isPathAllowed("/workspace/src")).toBe(true);
|
|
28
|
-
expect(mgr.isPathAllowed("/etc/passwd")).toBe(false);
|
|
31
|
+
test("blocks path traversal", () => {
|
|
32
|
+
expect(() => boundary.resolveWorkspacePath("../secret.txt")).toThrow();
|
|
29
33
|
});
|
|
30
34
|
|
|
31
35
|
test("describeCapabilities returns readable string", () => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
mgr.addPath("/workspace");
|
|
35
|
-
const desc = mgr.describeCapabilities();
|
|
36
|
+
boundary.addNetworkHost("api.anthropic.com");
|
|
37
|
+
const desc = boundary.describeCapabilities();
|
|
36
38
|
expect(desc).toContain("api.anthropic.com");
|
|
37
|
-
expect(desc).toContain(
|
|
39
|
+
expect(desc).toContain(workspace);
|
|
38
40
|
});
|
|
39
41
|
});
|
|
40
42
|
|
|
@@ -51,13 +53,6 @@ describe("ReviewGate", () => {
|
|
|
51
53
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
52
54
|
});
|
|
53
55
|
|
|
54
|
-
test("identifies high-risk tools", () => {
|
|
55
|
-
const gate = new ReviewGate(mail, "host@tps");
|
|
56
|
-
expect(gate.isHighRisk("git_push")).toBe(true);
|
|
57
|
-
expect(gate.isHighRisk("file_delete")).toBe(true);
|
|
58
|
-
expect(gate.isHighRisk("fs_read")).toBe(false);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
56
|
test("requestApproval sends mail to approver", async () => {
|
|
62
57
|
const gate = new ReviewGate(mail, "host@tps");
|
|
63
58
|
await gate.requestApproval("git_push", { branch: "main" });
|
|
@@ -73,14 +68,14 @@ describe("ToolRegistry", () => {
|
|
|
73
68
|
registry.register({
|
|
74
69
|
name: "echo",
|
|
75
70
|
description: "Echo input",
|
|
76
|
-
|
|
71
|
+
input_schema: { input: { type: "string" } },
|
|
77
72
|
async execute(args) {
|
|
78
|
-
return String(args.input);
|
|
73
|
+
return { content: String((args as any).input) };
|
|
79
74
|
},
|
|
80
75
|
});
|
|
81
76
|
|
|
82
77
|
const result = await registry.execute("echo", { input: "hello" });
|
|
83
|
-
expect(result).toBe("hello");
|
|
78
|
+
expect(result.content).toBe("hello");
|
|
84
79
|
});
|
|
85
80
|
|
|
86
81
|
test("throws for unknown tool", async () => {
|
|
@@ -93,10 +88,44 @@ describe("ToolRegistry", () => {
|
|
|
93
88
|
registry.register({
|
|
94
89
|
name: "echo",
|
|
95
90
|
description: "Echo input",
|
|
96
|
-
|
|
97
|
-
async execute() {
|
|
91
|
+
input_schema: { input: { type: "string" } },
|
|
92
|
+
async execute() {
|
|
93
|
+
return { content: "" };
|
|
94
|
+
},
|
|
98
95
|
});
|
|
99
96
|
expect(registry.list().length).toBe(1);
|
|
100
97
|
expect(registry.list()[0]!.name).toBe("echo");
|
|
101
98
|
});
|
|
102
99
|
});
|
|
100
|
+
|
|
101
|
+
describe("Read/Write/Edit tools", () => {
|
|
102
|
+
let tmpDir: string;
|
|
103
|
+
let boundary: BoundaryManager;
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
tmpDir = mkdtempSync(join(tmpdir(), "tps-tools-"));
|
|
107
|
+
boundary = new BoundaryManager(tmpDir);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("edit fails if old_string appears more than once", async () => {
|
|
115
|
+
const tool = makeEditTool(boundary);
|
|
116
|
+
const file = join(tmpDir, "a.txt");
|
|
117
|
+
writeFileSync(file, "abc abc", "utf-8");
|
|
118
|
+
const out = await tool.execute({ path: file, old_string: "abc", new_string: "xyz" });
|
|
119
|
+
expect(out.isError).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("write/read round trip", async () => {
|
|
123
|
+
const read = makeReadTool(boundary);
|
|
124
|
+
const write = makeWriteTool(boundary);
|
|
125
|
+
|
|
126
|
+
const result = await write.execute({ path: "foo.txt", content: "hello" });
|
|
127
|
+
expect(result.isError).toBe(false);
|
|
128
|
+
const readResult = await read.execute({ path: "foo.txt" });
|
|
129
|
+
expect(readResult.content).toContain("hello");
|
|
130
|
+
});
|
|
131
|
+
});
|
package/test/io.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { MailClient } from "../src/io/mail.js";
|
|
@@ -39,7 +39,6 @@ describe("MailClient", () => {
|
|
|
39
39
|
expect(msgs.length).toBe(1);
|
|
40
40
|
expect(msgs[0]!.body).toBe("hello world");
|
|
41
41
|
|
|
42
|
-
// File should now be in cur
|
|
43
42
|
const { existsSync } = await import("node:fs");
|
|
44
43
|
expect(existsSync(join(tmpDir, "inbox", "new", "test-1.json"))).toBe(false);
|
|
45
44
|
expect(existsSync(join(tmpDir, "inbox", "cur", "test-1.json"))).toBe(true);
|
|
@@ -74,7 +73,16 @@ describe("MemoryStore", () => {
|
|
|
74
73
|
expect(all[0]!.data).toBe("hello");
|
|
75
74
|
});
|
|
76
75
|
|
|
77
|
-
test("
|
|
76
|
+
test("redacts leaked secrets in stored JSON", () => {
|
|
77
|
+
process.env.OPENAI_API_KEY = "secret-token";
|
|
78
|
+
const store = new MemoryStore(join(tmpDir, "memory.jsonl"));
|
|
79
|
+
store.append({ type: "provider", ts: "2025-01-01T00:00:00Z", data: { raw: "Authorization: secret-token" } });
|
|
80
|
+
const all = store.readAll();
|
|
81
|
+
expect(String(all[0]!.data)).not.toContain("secret-token");
|
|
82
|
+
delete process.env.OPENAI_API_KEY;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("readAll returns empty for missing file", () => {
|
|
78
86
|
const store = new MemoryStore(join(tmpDir, "missing.jsonl"));
|
|
79
87
|
expect(store.readAll()).toEqual([]);
|
|
80
88
|
});
|
|
@@ -91,15 +99,15 @@ describe("ContextManager", () => {
|
|
|
91
99
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
92
100
|
});
|
|
93
101
|
|
|
94
|
-
test("getWindow returns empty for empty memory", () => {
|
|
102
|
+
test("getWindow returns empty for empty memory", async () => {
|
|
95
103
|
const store = new MemoryStore(join(tmpDir, "mem.jsonl"));
|
|
96
104
|
const ctx = new ContextManager(store, 1000);
|
|
97
|
-
expect(ctx.getWindow()).toEqual([]);
|
|
105
|
+
expect(await ctx.getWindow()).toEqual([]);
|
|
98
106
|
});
|
|
99
107
|
|
|
100
|
-
test("needsCompaction is false for empty memory", () => {
|
|
108
|
+
test("needsCompaction is false for empty memory", async () => {
|
|
101
109
|
const store = new MemoryStore(join(tmpDir, "mem.jsonl"));
|
|
102
110
|
const ctx = new ContextManager(store, 1000);
|
|
103
|
-
expect(ctx.needsCompaction()).toBe(false);
|
|
111
|
+
expect(await ctx.needsCompaction()).toBe(false);
|
|
104
112
|
});
|
|
105
113
|
});
|
package/test/runtime.test.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
5
6
|
import { AgentRuntime } from "../src/runtime/agent.js";
|
|
6
7
|
import type { AgentConfig } from "../src/runtime/types.js";
|
|
8
|
+
import { makeEditTool, makeExecTool } from "../src/tools/index.js";
|
|
7
9
|
|
|
8
10
|
describe("AgentRuntime", () => {
|
|
9
11
|
let tmpDir: string;
|
|
@@ -16,8 +18,9 @@ describe("AgentRuntime", () => {
|
|
|
16
18
|
name: "Test Agent",
|
|
17
19
|
mailDir: join(tmpDir, "mail"),
|
|
18
20
|
memoryPath: join(tmpDir, "memory.jsonl"),
|
|
21
|
+
workspace: tmpDir,
|
|
19
22
|
contextWindowTokens: 1000,
|
|
20
|
-
llm: { provider: "
|
|
23
|
+
llm: { provider: "openai", model: "gpt-4o-mini", apiKey: "x" },
|
|
21
24
|
};
|
|
22
25
|
});
|
|
23
26
|
|
|
@@ -35,11 +38,29 @@ describe("AgentRuntime", () => {
|
|
|
35
38
|
test("start() returns when stop() is called", async () => {
|
|
36
39
|
const runtime = new AgentRuntime(config);
|
|
37
40
|
|
|
38
|
-
// Start and stop after 50ms to avoid infinite loop
|
|
39
41
|
const startPromise = runtime.start();
|
|
40
42
|
setTimeout(() => runtime.stop(), 50);
|
|
41
43
|
await startPromise;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
expect(runtime.getState()).toBe("stopped");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("edit tool fails on ambiguous replacements", async () => {
|
|
48
|
+
const tool = makeEditTool({ resolveWorkspacePath: (p: string) => join(tmpDir, p) } as any);
|
|
49
|
+
const file = join(tmpDir, "sample.txt");
|
|
50
|
+
writeFileSync(file, "dup dup", "utf-8");
|
|
51
|
+
const out = await tool.execute({ path: "sample.txt", old_string: "dup", new_string: "x" } as any);
|
|
52
|
+
expect(out.isError).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("exec tool preserves no shell injection via args array", async () => {
|
|
56
|
+
const tool = makeExecTool({
|
|
57
|
+
resolveWorkspacePath: (p: string) => join(tmpDir, p),
|
|
58
|
+
validateCommand: () => undefined,
|
|
59
|
+
scrubEnvironment: () => ({ PATH: process.env.PATH }),
|
|
60
|
+
} as any, ["git"]);
|
|
61
|
+
|
|
62
|
+
const result = await tool.execute({ command: "git", args: ["--version"] } as any);
|
|
63
|
+
expect(result.isError).toBe(false);
|
|
64
|
+
expect(result.content).toContain("git version");
|
|
44
65
|
});
|
|
45
66
|
});
|