@twinklerg/coden 0.1.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/LICENSE +21 -0
- package/README.md +219 -0
- package/dist/index.js +21269 -0
- package/package.json +48 -0
- package/src/cli/agent-command.ts +497 -0
- package/src/cli/format.ts +42 -0
- package/src/cli/index.ts +149 -0
- package/src/cli/plugin-command.ts +217 -0
- package/src/config/config.ts +96 -0
- package/src/config/trust.ts +35 -0
- package/src/context/manager.ts +186 -0
- package/src/context/truncate.ts +9 -0
- package/src/core/events.ts +32 -0
- package/src/core/runtime.ts +402 -0
- package/src/core/types.ts +97 -0
- package/src/index.ts +14 -0
- package/src/observability/terminal.ts +201 -0
- package/src/observability/trace.ts +30 -0
- package/src/permissions/policy.ts +56 -0
- package/src/permissions/workspace.ts +139 -0
- package/src/plugins/api.ts +68 -0
- package/src/plugins/bun-package-manager.ts +35 -0
- package/src/plugins/installed-loader.ts +144 -0
- package/src/plugins/installer.ts +314 -0
- package/src/plugins/manifest.ts +89 -0
- package/src/plugins/package-manager.ts +10 -0
- package/src/plugins/package-metadata.ts +95 -0
- package/src/plugins/paths.ts +43 -0
- package/src/plugins/specifier.ts +63 -0
- package/src/plugins/transaction.ts +403 -0
- package/src/process/runner.ts +134 -0
- package/src/providers/anthropic.ts +117 -0
- package/src/providers/openai.ts +96 -0
- package/src/providers/scripted.ts +28 -0
- package/src/sessions/store.ts +278 -0
- package/src/tools/builtin/bash.ts +56 -0
- package/src/tools/builtin/edit.ts +42 -0
- package/src/tools/builtin/index.ts +9 -0
- package/src/tools/builtin/read.ts +91 -0
- package/src/tools/builtin/write.ts +34 -0
- package/src/tools/executor.ts +90 -0
- package/src/tools/plugin-loader.ts +122 -0
- package/src/tools/registry.ts +97 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import * as readline from "node:readline";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import type { EventBus, RuntimeEvent } from "../core/events.js";
|
|
4
|
+
|
|
5
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
6
|
+
|
|
7
|
+
export interface TerminalOptions {
|
|
8
|
+
stdout?: NodeJS.WritableStream;
|
|
9
|
+
stderr?: NodeJS.WritableStream;
|
|
10
|
+
tty?: boolean;
|
|
11
|
+
verbose?: boolean;
|
|
12
|
+
printMode?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export class TerminalRenderer {
|
|
15
|
+
private readonly stdout: NodeJS.WritableStream;
|
|
16
|
+
private readonly stderr: NodeJS.WritableStream;
|
|
17
|
+
private readonly tty: boolean;
|
|
18
|
+
private spinner: NodeJS.Timeout | undefined;
|
|
19
|
+
private frame = 0;
|
|
20
|
+
private providerStartedAt: number | undefined;
|
|
21
|
+
private reasoningText = "";
|
|
22
|
+
private contentStarted = false;
|
|
23
|
+
// Non-TTY output is buffered until the provider attempt succeeds so that a
|
|
24
|
+
// failed stream attempt followed by a retry cannot corrupt pipeline output.
|
|
25
|
+
private pendingText = "";
|
|
26
|
+
constructor(
|
|
27
|
+
events: EventBus,
|
|
28
|
+
private readonly options: TerminalOptions = {},
|
|
29
|
+
) {
|
|
30
|
+
this.stdout = options.stdout ?? process.stdout;
|
|
31
|
+
this.stderr = options.stderr ?? process.stderr;
|
|
32
|
+
this.tty =
|
|
33
|
+
options.tty ?? Boolean(process.stderr.isTTY && !process.env.NO_COLOR && !process.env.CI);
|
|
34
|
+
events.on((event) => this.render(event));
|
|
35
|
+
}
|
|
36
|
+
private render(event: RuntimeEvent): void {
|
|
37
|
+
if (event.type === "provider.started") this.startProviderAttempt();
|
|
38
|
+
if (event.type === "provider.reasoning_delta") {
|
|
39
|
+
const text = String(event.data?.text ?? "");
|
|
40
|
+
if (this.tty && this.providerStartedAt !== undefined && !this.contentStarted && text) {
|
|
41
|
+
this.reasoningText += text;
|
|
42
|
+
this.renderThinkingLine();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (event.type === "provider.delta") {
|
|
46
|
+
const text = String(event.data?.text ?? "");
|
|
47
|
+
if (text && !this.contentStarted) this.finishThinking();
|
|
48
|
+
if (this.tty) this.stdout.write(text);
|
|
49
|
+
else this.pendingText += text;
|
|
50
|
+
}
|
|
51
|
+
if (event.type === "provider.completed") {
|
|
52
|
+
if (!this.tty && this.pendingText) {
|
|
53
|
+
this.stdout.write(this.pendingText);
|
|
54
|
+
this.pendingText = "";
|
|
55
|
+
}
|
|
56
|
+
this.endProviderAttempt();
|
|
57
|
+
}
|
|
58
|
+
if (event.type === "provider.retry" || event.type === "turn.failed") {
|
|
59
|
+
this.endProviderAttempt();
|
|
60
|
+
this.pendingText = "";
|
|
61
|
+
}
|
|
62
|
+
if (event.type === "tool.started") {
|
|
63
|
+
this.endProviderAttempt();
|
|
64
|
+
this.status(`tool ${String(event.data?.name)} started`);
|
|
65
|
+
}
|
|
66
|
+
if (event.type === "tool.completed")
|
|
67
|
+
this.status(
|
|
68
|
+
`tool ${String(event.data?.name)} ${event.data?.isError ? "failed" : "completed"} (${String(event.data?.durationMs)}ms)`,
|
|
69
|
+
);
|
|
70
|
+
if (event.type === "provider.retry" && this.options.verbose)
|
|
71
|
+
this.status(`provider retry ${String(event.data?.attempt)}`);
|
|
72
|
+
if (event.type === "turn.completed") {
|
|
73
|
+
this.endProviderAttempt();
|
|
74
|
+
this.stdout.write("\n");
|
|
75
|
+
this.status(
|
|
76
|
+
`done: ${String(event.data?.tools)} tools, ${String(event.data?.durationMs)}ms, ${String(event.data?.inputTokens)}/${String(event.data?.outputTokens)} tokens`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (event.type === "turn.failed") {
|
|
80
|
+
this.status(pc.red(`failed: ${String(event.data?.message)}`));
|
|
81
|
+
}
|
|
82
|
+
if (event.type === "plugin.loaded" && this.options.verbose) {
|
|
83
|
+
this.status(`plugin loaded: ${pluginLabel(event)}`);
|
|
84
|
+
}
|
|
85
|
+
if (event.type === "plugin.failed") {
|
|
86
|
+
this.status(pc.red(`plugin failed: ${pluginLabel(event)}${eventMessage(event)}`));
|
|
87
|
+
}
|
|
88
|
+
if (event.type === "plugin.unavailable") {
|
|
89
|
+
this.status(pc.yellow(`plugin unavailable: ${pluginLabel(event)}${eventMessage(event)}`));
|
|
90
|
+
}
|
|
91
|
+
if (event.type === "plugin.restart_required") {
|
|
92
|
+
this.status(
|
|
93
|
+
pc.yellow(`plugin restart required: ${pluginLabel(event)}${eventMessage(event)}`),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
private status(message: string): void {
|
|
98
|
+
this.stderr.write(this.tty ? `${pc.dim(message)}\n` : `[coden] ${message}\n`);
|
|
99
|
+
}
|
|
100
|
+
private startProviderAttempt(): void {
|
|
101
|
+
this.endProviderAttempt();
|
|
102
|
+
this.providerStartedAt = Date.now();
|
|
103
|
+
this.startSpinner();
|
|
104
|
+
}
|
|
105
|
+
private endProviderAttempt(): void {
|
|
106
|
+
this.stopSpinner();
|
|
107
|
+
this.providerStartedAt = undefined;
|
|
108
|
+
this.reasoningText = "";
|
|
109
|
+
this.contentStarted = false;
|
|
110
|
+
}
|
|
111
|
+
private finishThinking(): void {
|
|
112
|
+
const startedAt = this.providerStartedAt;
|
|
113
|
+
const hadReasoning = Boolean(this.normalizedReasoning());
|
|
114
|
+
this.stopSpinner();
|
|
115
|
+
this.contentStarted = true;
|
|
116
|
+
if (this.tty && startedAt !== undefined && hadReasoning) {
|
|
117
|
+
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
118
|
+
this.stderr.write(`${pc.dim(`thought for ${seconds}s`)}\n`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
private normalizedReasoning(): string {
|
|
122
|
+
return this.reasoningText.replace(/\s+/g, " ").trim();
|
|
123
|
+
}
|
|
124
|
+
private renderThinkingLine(): void {
|
|
125
|
+
const normalized = this.normalizedReasoning();
|
|
126
|
+
if (!normalized) return;
|
|
127
|
+
const columns = (this.stderr as NodeJS.WritableStream & { columns?: number }).columns ?? 80;
|
|
128
|
+
const frame = SPINNER_FRAMES[this.frame++ % SPINNER_FRAMES.length] ?? "";
|
|
129
|
+
const visible = this.truncateTail(normalized, Math.max(0, columns - 2));
|
|
130
|
+
readline.clearLine(this.stderr, 0);
|
|
131
|
+
readline.cursorTo(this.stderr, 0);
|
|
132
|
+
this.stderr.write(pc.dim(`${frame} ${visible}`));
|
|
133
|
+
}
|
|
134
|
+
private truncateTail(text: string, maxColumns: number): string {
|
|
135
|
+
if (maxColumns <= 0) return "";
|
|
136
|
+
const characters = Array.from(text);
|
|
137
|
+
const width = (character: string) => ((character.codePointAt(0) ?? 0) <= 0xff ? 1 : 2);
|
|
138
|
+
const total = characters.reduce((sum, character) => sum + width(character), 0);
|
|
139
|
+
if (total <= maxColumns) return text;
|
|
140
|
+
const kept: string[] = [];
|
|
141
|
+
let used = 1;
|
|
142
|
+
for (let index = characters.length - 1; index >= 0; index--) {
|
|
143
|
+
const character = characters[index];
|
|
144
|
+
if (character === undefined || used + width(character) > maxColumns) break;
|
|
145
|
+
kept.unshift(character);
|
|
146
|
+
used += width(character);
|
|
147
|
+
}
|
|
148
|
+
return `…${kept.join("")}`;
|
|
149
|
+
}
|
|
150
|
+
private startSpinner(): void {
|
|
151
|
+
if (!this.tty || this.spinner) {
|
|
152
|
+
if (!this.tty && this.options.verbose) this.status("requesting model");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.spinner = setInterval(() => {
|
|
156
|
+
if (this.normalizedReasoning()) {
|
|
157
|
+
this.renderThinkingLine();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
readline.clearLine(this.stderr, 0);
|
|
161
|
+
readline.cursorTo(this.stderr, 0);
|
|
162
|
+
const frame = SPINNER_FRAMES[this.frame++ % SPINNER_FRAMES.length] ?? "";
|
|
163
|
+
this.stderr.write(`${frame} thinking`);
|
|
164
|
+
}, 80);
|
|
165
|
+
}
|
|
166
|
+
private stopSpinner(): void {
|
|
167
|
+
if (!this.spinner) return;
|
|
168
|
+
clearInterval(this.spinner);
|
|
169
|
+
this.spinner = undefined;
|
|
170
|
+
readline.clearLine(this.stderr, 0);
|
|
171
|
+
readline.cursorTo(this.stderr, 0);
|
|
172
|
+
}
|
|
173
|
+
dispose(): void {
|
|
174
|
+
this.endProviderAttempt();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function pluginLabel(event: RuntimeEvent): string {
|
|
179
|
+
const data = event.data ?? {};
|
|
180
|
+
const scope = stringValue(data.scope);
|
|
181
|
+
const packageName = stringValue(data.packageName);
|
|
182
|
+
const version = stringValue(data.version ?? data.diskVersion);
|
|
183
|
+
const name = stringValue(data.name);
|
|
184
|
+
const path = stringValue(data.path);
|
|
185
|
+
const source = stringValue(data.source);
|
|
186
|
+
if (packageName)
|
|
187
|
+
return `${scope ? `${scope} ` : ""}${packageName}${version ? `@${version}` : ""}`;
|
|
188
|
+
if (name) return name;
|
|
189
|
+
if (path) return `${scope ? `${scope} ` : ""}${path}`;
|
|
190
|
+
if (source) return source;
|
|
191
|
+
return "plugin";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function eventMessage(event: RuntimeEvent): string {
|
|
195
|
+
const message = stringValue(event.data?.message ?? event.data?.reason);
|
|
196
|
+
return message ? ` — ${message}` : "";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function stringValue(value: unknown): string | undefined {
|
|
200
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
201
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { appendFile, chmod, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { EventBus, RuntimeEvent } from "../core/events.js";
|
|
4
|
+
|
|
5
|
+
export class JSONLTraceWriter {
|
|
6
|
+
#queue = Promise.resolve();
|
|
7
|
+
constructor(
|
|
8
|
+
private readonly file: string,
|
|
9
|
+
events: EventBus,
|
|
10
|
+
) {
|
|
11
|
+
events.on((event) => this.write(event));
|
|
12
|
+
}
|
|
13
|
+
write(event: RuntimeEvent): Promise<void> {
|
|
14
|
+
const operation = this.#queue.then(async () => {
|
|
15
|
+
const directory = path.dirname(this.file);
|
|
16
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
17
|
+
await chmod(directory, 0o700);
|
|
18
|
+
await appendFile(this.file, `${JSON.stringify(event)}\n`, {
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
mode: 0o600,
|
|
21
|
+
});
|
|
22
|
+
await chmod(this.file, 0o600);
|
|
23
|
+
});
|
|
24
|
+
this.#queue = operation.catch(() => {});
|
|
25
|
+
return operation;
|
|
26
|
+
}
|
|
27
|
+
async flush(): Promise<void> {
|
|
28
|
+
await this.#queue;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ToolCall, ToolDefinition, ToolRisk } from "../core/types.js";
|
|
2
|
+
|
|
3
|
+
export type PermissionDecision = "allow_once" | "allow_session" | "deny";
|
|
4
|
+
export type PermissionPrompt = (
|
|
5
|
+
tool: ToolDefinition,
|
|
6
|
+
call: ToolCall,
|
|
7
|
+
risk: ToolRisk,
|
|
8
|
+
signal?: AbortSignal,
|
|
9
|
+
) => Promise<PermissionDecision>;
|
|
10
|
+
|
|
11
|
+
const DANGEROUS = [
|
|
12
|
+
/\brm\s+(?:-[^\s]*r[^\s]*\s+|--recursive\b)/i,
|
|
13
|
+
/\bsudo\b/i,
|
|
14
|
+
/\bgit\b[^;&|\n]*\breset\s+--hard\b/i,
|
|
15
|
+
/\bgit\b[^;&|\n]*\bclean\s+(?:-[^\s]*f[^\s]*|--force)\b/i,
|
|
16
|
+
/\bgit\b[^;&|\n]*\bpush\b[^;&|\n]*(?:--force(?:-with-lease)?|-f)\b/i,
|
|
17
|
+
/\bgit\b[^;&|\n]*\bcheckout\s+--\s/i,
|
|
18
|
+
/\bgit\b[^;&|\n]*\brestore\b/i,
|
|
19
|
+
/\b(?:mkfs|fdisk|dd)\b/i,
|
|
20
|
+
/\b(?:killall|pkill)\b/i,
|
|
21
|
+
/(?:curl|wget)[^|;&]*(?:\||&&)\s*(?:sh|bash)\b/i,
|
|
22
|
+
/(?:^|\s)(?:\/etc|\/usr|\/bin|\/sbin)\//,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function classifyBashRisk(command: string): ToolRisk {
|
|
26
|
+
return DANGEROUS.some((pattern) => pattern.test(command)) ? "dangerous" : "modify";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class PermissionPolicy {
|
|
30
|
+
readonly #sessionAllowed = new Set<string>();
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly auto: boolean,
|
|
33
|
+
private readonly prompt?: PermissionPrompt,
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
async authorize(
|
|
37
|
+
tool: ToolDefinition,
|
|
38
|
+
call: ToolCall,
|
|
39
|
+
signal?: AbortSignal,
|
|
40
|
+
): Promise<{ allowed: boolean; risk: ToolRisk }> {
|
|
41
|
+
let risk = tool.risk;
|
|
42
|
+
if (
|
|
43
|
+
tool.name === "bash" &&
|
|
44
|
+
typeof (call.input as { command?: unknown })?.command === "string"
|
|
45
|
+
) {
|
|
46
|
+
const classified = classifyBashRisk((call.input as { command: string }).command);
|
|
47
|
+
if (classified === "dangerous") risk = "dangerous";
|
|
48
|
+
}
|
|
49
|
+
if (this.auto || risk === "read" || (risk === "modify" && this.#sessionAllowed.has(tool.name)))
|
|
50
|
+
return { allowed: true, risk };
|
|
51
|
+
if (!this.prompt) return { allowed: false, risk };
|
|
52
|
+
const decision = await this.prompt(tool, call, risk, signal);
|
|
53
|
+
if (decision === "allow_session" && risk !== "dangerous") this.#sessionAllowed.add(tool.name);
|
|
54
|
+
return { allowed: decision !== "deny", risk };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open, readlink, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CodeNError } from "../core/types.js";
|
|
5
|
+
|
|
6
|
+
function isInside(child: string, parent: string): boolean {
|
|
7
|
+
const relative = path.relative(parent, child);
|
|
8
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function readWorkspaceTextFile(
|
|
12
|
+
workspace: string,
|
|
13
|
+
requested: string,
|
|
14
|
+
maxBytes = 1_000_000,
|
|
15
|
+
): Promise<string> {
|
|
16
|
+
const target = await resolveWorkspacePath(workspace, requested);
|
|
17
|
+
const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
18
|
+
try {
|
|
19
|
+
const fileStat = await handle.stat();
|
|
20
|
+
if (fileStat.size > maxBytes)
|
|
21
|
+
throw new CodeNError(
|
|
22
|
+
"context",
|
|
23
|
+
"workspace.instructions_too_large",
|
|
24
|
+
`${requested} exceeds the ${maxBytes}-byte instruction limit`,
|
|
25
|
+
);
|
|
26
|
+
return await handle.readFile("utf8");
|
|
27
|
+
} finally {
|
|
28
|
+
await handle.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function resolveWorkspacePath(workspace: string, requested: string): Promise<string> {
|
|
33
|
+
const workspaceReal = await realpath(workspace);
|
|
34
|
+
const absolute = path.resolve(workspaceReal, requested);
|
|
35
|
+
if (!isInside(absolute, workspaceReal))
|
|
36
|
+
throw new CodeNError(
|
|
37
|
+
"permission",
|
|
38
|
+
"workspace.outside",
|
|
39
|
+
`Path is outside workspace: ${requested}`,
|
|
40
|
+
);
|
|
41
|
+
try {
|
|
42
|
+
const targetReal = await realpath(absolute);
|
|
43
|
+
if (!isInside(targetReal, workspaceReal))
|
|
44
|
+
throw new CodeNError(
|
|
45
|
+
"permission",
|
|
46
|
+
"workspace.symlink_escape",
|
|
47
|
+
`Path escapes workspace through a symlink: ${requested}`,
|
|
48
|
+
);
|
|
49
|
+
return targetReal;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof CodeNError) throw error;
|
|
52
|
+
// The target does not exist or is a dangling symlink. Validate every
|
|
53
|
+
// existing component (including symlink target chains) before returning.
|
|
54
|
+
try {
|
|
55
|
+
const targetStat = await lstat(absolute);
|
|
56
|
+
if (targetStat.isSymbolicLink()) {
|
|
57
|
+
const linkTarget = path.resolve(path.dirname(absolute), await readlink(absolute));
|
|
58
|
+
await assertResolvedInside(linkTarget, workspaceReal, requested, "dangling symlink");
|
|
59
|
+
}
|
|
60
|
+
} catch (linkError) {
|
|
61
|
+
if (linkError instanceof CodeNError) throw linkError;
|
|
62
|
+
}
|
|
63
|
+
await assertAncestorsInside(absolute, workspaceReal, requested);
|
|
64
|
+
return absolute;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function assertResolvedInside(
|
|
69
|
+
target: string,
|
|
70
|
+
workspaceReal: string,
|
|
71
|
+
requested: string,
|
|
72
|
+
kind: string,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const resolved = await nearestExistingRealpath(target);
|
|
75
|
+
if (!resolved || !isInside(resolved, workspaceReal))
|
|
76
|
+
throw new CodeNError(
|
|
77
|
+
"permission",
|
|
78
|
+
"workspace.symlink_escape",
|
|
79
|
+
`Path escapes workspace through a ${kind}: ${requested}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function assertAncestorsInside(
|
|
84
|
+
absolute: string,
|
|
85
|
+
workspaceReal: string,
|
|
86
|
+
requested: string,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
let parent = path.dirname(absolute);
|
|
89
|
+
while (parent !== path.dirname(parent)) {
|
|
90
|
+
let statResult: Awaited<ReturnType<typeof lstat>> | undefined;
|
|
91
|
+
try {
|
|
92
|
+
statResult = await lstat(parent);
|
|
93
|
+
} catch {
|
|
94
|
+
parent = path.dirname(parent);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!statResult) {
|
|
98
|
+
parent = path.dirname(parent);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (statResult.isSymbolicLink()) {
|
|
102
|
+
// An intermediate symlink (possibly dangling) must resolve inside the
|
|
103
|
+
// workspace even when the final component does not exist yet.
|
|
104
|
+
const linkTarget = path.resolve(path.dirname(parent), await readlink(parent));
|
|
105
|
+
await assertResolvedInside(linkTarget, workspaceReal, requested, "symlink");
|
|
106
|
+
} else {
|
|
107
|
+
let parentReal: string | undefined;
|
|
108
|
+
try {
|
|
109
|
+
parentReal = await realpath(parent);
|
|
110
|
+
} catch {
|
|
111
|
+
parent = path.dirname(parent);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!parentReal) continue;
|
|
115
|
+
// The deepest existing ancestor is a real directory. If it is inside the
|
|
116
|
+
// workspace we are done; upper levels are the workspace's own ancestors.
|
|
117
|
+
if (isInside(parentReal, workspaceReal)) return;
|
|
118
|
+
throw new CodeNError(
|
|
119
|
+
"permission",
|
|
120
|
+
"workspace.symlink_escape",
|
|
121
|
+
`Parent escapes workspace: ${requested}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
parent = path.dirname(parent);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function nearestExistingRealpath(target: string): Promise<string | undefined> {
|
|
129
|
+
let current = target;
|
|
130
|
+
while (true) {
|
|
131
|
+
try {
|
|
132
|
+
return await realpath(current);
|
|
133
|
+
} catch {
|
|
134
|
+
const parent = path.dirname(current);
|
|
135
|
+
if (parent === current) return undefined;
|
|
136
|
+
current = parent;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { CodeNError, type ToolDefinition } from "../core/types.js";
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
JsonSchema,
|
|
5
|
+
ToolContext,
|
|
6
|
+
ToolDefinition,
|
|
7
|
+
ToolResult,
|
|
8
|
+
ToolRisk,
|
|
9
|
+
} from "../core/types.js";
|
|
10
|
+
|
|
11
|
+
export const CODEN_PLUGIN_API_VERSION = 1 as const;
|
|
12
|
+
|
|
13
|
+
export interface CodeNPlugin {
|
|
14
|
+
apiVersion: typeof CODEN_PLUGIN_API_VERSION;
|
|
15
|
+
name: string;
|
|
16
|
+
tools: ToolDefinition[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type PluginModuleExport = ToolDefinition | CodeNPlugin;
|
|
20
|
+
|
|
21
|
+
export function normalizePluginExport(value: unknown, packageName: string): ToolDefinition[] {
|
|
22
|
+
if (isToolDefinitionShape(value)) return [value];
|
|
23
|
+
if (!value || typeof value !== "object")
|
|
24
|
+
throw new CodeNError(
|
|
25
|
+
"plugin",
|
|
26
|
+
"plugin.export_invalid",
|
|
27
|
+
"plugin.export_invalid: default export must be a tool or CodeNPlugin",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const plugin = value as Partial<CodeNPlugin> & { apiVersion?: unknown; tools?: unknown };
|
|
31
|
+
if (plugin.apiVersion !== CODEN_PLUGIN_API_VERSION)
|
|
32
|
+
throw new CodeNError(
|
|
33
|
+
"plugin",
|
|
34
|
+
"plugin.api_unsupported",
|
|
35
|
+
`plugin.api_unsupported: ${String(plugin.apiVersion)}`,
|
|
36
|
+
);
|
|
37
|
+
if (plugin.name !== packageName)
|
|
38
|
+
throw new CodeNError(
|
|
39
|
+
"plugin",
|
|
40
|
+
"plugin.name_mismatch",
|
|
41
|
+
`plugin.name_mismatch: expected ${packageName}, received ${String(plugin.name)}`,
|
|
42
|
+
);
|
|
43
|
+
if (!Array.isArray(plugin.tools) || plugin.tools.length === 0)
|
|
44
|
+
throw new CodeNError(
|
|
45
|
+
"plugin",
|
|
46
|
+
"plugin.export_invalid",
|
|
47
|
+
"plugin.export_invalid: tools must be a non-empty array",
|
|
48
|
+
);
|
|
49
|
+
if (!plugin.tools.every(isToolDefinitionShape))
|
|
50
|
+
throw new CodeNError(
|
|
51
|
+
"plugin",
|
|
52
|
+
"plugin.export_invalid",
|
|
53
|
+
"plugin.export_invalid: every tool must match ToolDefinition",
|
|
54
|
+
);
|
|
55
|
+
return plugin.tools;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isToolDefinitionShape(value: unknown): value is ToolDefinition {
|
|
59
|
+
if (!value || typeof value !== "object") return false;
|
|
60
|
+
const tool = value as Partial<ToolDefinition>;
|
|
61
|
+
return (
|
|
62
|
+
typeof tool.name === "string" &&
|
|
63
|
+
typeof tool.description === "string" &&
|
|
64
|
+
(tool.risk === "read" || tool.risk === "modify" || tool.risk === "dangerous") &&
|
|
65
|
+
!!tool.inputSchema &&
|
|
66
|
+
typeof tool.execute === "function"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { truncateOutput } from "../context/truncate.js";
|
|
2
|
+
import { CodeNError } from "../core/types.js";
|
|
3
|
+
import { type ProcessRunner, type ProcessRunResult, runProcess } from "../process/runner.js";
|
|
4
|
+
import type { PackageInstallRequest, PackageManager } from "./package-manager.js";
|
|
5
|
+
|
|
6
|
+
function boundedInstallMessage(result: ProcessRunResult): string {
|
|
7
|
+
const status = result.timedOut
|
|
8
|
+
? "bun install timed out"
|
|
9
|
+
: result.cancelled
|
|
10
|
+
? "bun install cancelled"
|
|
11
|
+
: `bun install exited with code ${result.exitCode ?? "null"}${result.signal ? ` (signal ${result.signal})` : ""}`;
|
|
12
|
+
return truncateOutput(
|
|
13
|
+
[status, result.stderr ? `stderr:\n${result.stderr}` : "stderr:\n<empty>"].join("\n"),
|
|
14
|
+
4_000,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class BunPackageManager implements PackageManager {
|
|
19
|
+
constructor(private readonly runner: ProcessRunner = runProcess) {}
|
|
20
|
+
|
|
21
|
+
async install(request: PackageInstallRequest): Promise<void> {
|
|
22
|
+
const args = ["install", "--registry", "https://registry.npmjs.org"];
|
|
23
|
+
if (request.frozenLockfile) args.push("--frozen-lockfile");
|
|
24
|
+
if (!request.allowScripts) args.push("--ignore-scripts");
|
|
25
|
+
const result = await this.runner("bun", args, {
|
|
26
|
+
cwd: request.cwd,
|
|
27
|
+
env: process.env,
|
|
28
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
29
|
+
timeoutMs: 120_000,
|
|
30
|
+
maxOutputChars: 30_000,
|
|
31
|
+
});
|
|
32
|
+
if (!result.ok)
|
|
33
|
+
throw new CodeNError("plugin", "plugin.install_failed", boundedInstallMessage(result));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { CodeNError, type ToolDefinition } from "../core/types.js";
|
|
4
|
+
import { ToolRegistry, type ToolSource } from "../tools/registry.js";
|
|
5
|
+
import { normalizePluginExport } from "./api.js";
|
|
6
|
+
import { readPluginManifest } from "./manifest.js";
|
|
7
|
+
import { readInstalledPackageMetadata } from "./package-metadata.js";
|
|
8
|
+
import type { PluginPaths } from "./paths.js";
|
|
9
|
+
|
|
10
|
+
export interface LoadedPackagePlugin {
|
|
11
|
+
packageName: string;
|
|
12
|
+
version: string;
|
|
13
|
+
entryPath: string;
|
|
14
|
+
tools: ToolDefinition[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PackagePluginFailure {
|
|
18
|
+
packageName: string;
|
|
19
|
+
path: string;
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PackagePluginLoadResult {
|
|
24
|
+
loaded: LoadedPackagePlugin[];
|
|
25
|
+
failed: PackagePluginFailure[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type PackageImporter = (specifier: string) => Promise<{ default?: unknown }>;
|
|
29
|
+
|
|
30
|
+
export interface ShadowedPackage {
|
|
31
|
+
packageName: string;
|
|
32
|
+
globalVersion: string;
|
|
33
|
+
projectVersion: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class InstalledPluginLoader {
|
|
37
|
+
constructor(private readonly importer: PackageImporter = (specifier) => import(specifier)) {}
|
|
38
|
+
|
|
39
|
+
async loadScope(paths: PluginPaths): Promise<PackagePluginLoadResult> {
|
|
40
|
+
const manifest = await readPluginManifest(paths.manifestPath);
|
|
41
|
+
const loaded: LoadedPackagePlugin[] = [];
|
|
42
|
+
const failed: PackagePluginFailure[] = [];
|
|
43
|
+
|
|
44
|
+
for (const packageName of Object.keys(manifest.plugins).sort()) {
|
|
45
|
+
const packageDirectory = packageDirectoryFor(paths.runtimeDir, packageName);
|
|
46
|
+
try {
|
|
47
|
+
const metadata = await readInstalledPackageMetadata(paths.runtimeDir, packageName);
|
|
48
|
+
try {
|
|
49
|
+
const module = await this.importer(pathToFileURL(metadata.entryPath).href);
|
|
50
|
+
const tools = normalizePluginExport(module.default, packageName);
|
|
51
|
+
loaded.push({
|
|
52
|
+
packageName,
|
|
53
|
+
version: metadata.version,
|
|
54
|
+
entryPath: metadata.entryPath,
|
|
55
|
+
tools,
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
failed.push({
|
|
59
|
+
packageName,
|
|
60
|
+
path: metadata.entryPath,
|
|
61
|
+
message: errorMessage(error),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
failed.push({
|
|
66
|
+
packageName,
|
|
67
|
+
path: packageDirectory,
|
|
68
|
+
message: errorMessage(error),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { loaded, failed };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function composePackageRegistry(
|
|
78
|
+
builtins: ToolDefinition[],
|
|
79
|
+
globalPlugins: LoadedPackagePlugin[],
|
|
80
|
+
projectPlugins: LoadedPackagePlugin[],
|
|
81
|
+
): { registry: ToolRegistry; effective: LoadedPackagePlugin[]; shadowed: ShadowedPackage[] } {
|
|
82
|
+
const registry = new ToolRegistry(builtins);
|
|
83
|
+
const projectByName = new Map(projectPlugins.map((plugin) => [plugin.packageName, plugin]));
|
|
84
|
+
const shadowed: ShadowedPackage[] = [];
|
|
85
|
+
const effectiveGlobals: LoadedPackagePlugin[] = [];
|
|
86
|
+
|
|
87
|
+
for (const plugin of globalPlugins) {
|
|
88
|
+
const projectPlugin = projectByName.get(plugin.packageName);
|
|
89
|
+
if (projectPlugin) {
|
|
90
|
+
shadowed.push({
|
|
91
|
+
packageName: plugin.packageName,
|
|
92
|
+
globalVersion: plugin.version,
|
|
93
|
+
projectVersion: projectPlugin.version,
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
effectiveGlobals.push(plugin);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const effective = [...effectiveGlobals, ...projectPlugins];
|
|
101
|
+
for (const plugin of effective) registerPackageTools(registry, plugin);
|
|
102
|
+
return { registry, effective, shadowed };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function registerPackageTools(registry: ToolRegistry, plugin: LoadedPackagePlugin): void {
|
|
106
|
+
const source: ToolSource = {
|
|
107
|
+
kind: "npm",
|
|
108
|
+
pluginName: plugin.packageName,
|
|
109
|
+
pluginVersion: plugin.version,
|
|
110
|
+
path: plugin.entryPath,
|
|
111
|
+
};
|
|
112
|
+
for (const tool of plugin.tools) {
|
|
113
|
+
const existingSource = registry.source(tool.name);
|
|
114
|
+
if (existingSource) {
|
|
115
|
+
throw new CodeNError(
|
|
116
|
+
"plugin",
|
|
117
|
+
"plugin.tool_conflict",
|
|
118
|
+
`plugin.tool_conflict: ${tool.name} from ${formatSource(existingSource)} conflicts with ${formatSource(source)}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
registry.register(tool, source);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function packageDirectoryFor(runtimeDir: string, packageName: string): string {
|
|
126
|
+
return path.join(runtimeDir, "node_modules", ...packageName.split("/"));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatSource(source: ToolSource): string {
|
|
130
|
+
switch (source.kind) {
|
|
131
|
+
case "builtin":
|
|
132
|
+
return "builtin";
|
|
133
|
+
case "local":
|
|
134
|
+
return source.path ? `local:${source.path}` : "local";
|
|
135
|
+
case "npm":
|
|
136
|
+
return source.path
|
|
137
|
+
? `${source.pluginName}@${source.pluginVersion}:${source.path}`
|
|
138
|
+
: `${source.pluginName}@${source.pluginVersion}`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function errorMessage(error: unknown): string {
|
|
143
|
+
return error instanceof Error ? error.message : String(error);
|
|
144
|
+
}
|