@nifrajs/coding-agent 3.2.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/EXTENSIONS.md +25 -0
- package/PERFORMANCE.md +19 -0
- package/README.md +69 -0
- package/REGISTRY.md +15 -0
- package/SECURITY.md +32 -0
- package/dist/approvals.d.ts +70 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +174 -0
- package/dist/approvals.js.map +1 -0
- package/dist/capabilities.d.ts +20 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +49 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +380 -0
- package/dist/cli.js.map +1 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +66 -0
- package/dist/context.js.map +1 -0
- package/dist/deployment-adapters.d.ts +40 -0
- package/dist/deployment-adapters.d.ts.map +1 -0
- package/dist/deployment-adapters.js +147 -0
- package/dist/deployment-adapters.js.map +1 -0
- package/dist/diff.d.ts +17 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +61 -0
- package/dist/diff.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/extensions.d.ts +104 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +284 -0
- package/dist/extensions.js.map +1 -0
- package/dist/handoffs.d.ts +97 -0
- package/dist/handoffs.d.ts.map +1 -0
- package/dist/handoffs.js +199 -0
- package/dist/handoffs.js.map +1 -0
- package/dist/healing.d.ts +56 -0
- package/dist/healing.d.ts.map +1 -0
- package/dist/healing.js +54 -0
- package/dist/healing.js.map +1 -0
- package/dist/host.d.ts +61 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +369 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/isolated-worker.d.ts +2 -0
- package/dist/isolated-worker.d.ts.map +1 -0
- package/dist/isolated-worker.js +155 -0
- package/dist/isolated-worker.js.map +1 -0
- package/dist/isolated.d.ts +52 -0
- package/dist/isolated.d.ts.map +1 -0
- package/dist/isolated.js +293 -0
- package/dist/isolated.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +339 -0
- package/dist/native.js.map +1 -0
- package/dist/orchestration/artifact-port.d.ts +30 -0
- package/dist/orchestration/artifact-port.d.ts.map +1 -0
- package/dist/orchestration/artifact-port.js +59 -0
- package/dist/orchestration/artifact-port.js.map +1 -0
- package/dist/orchestration/catalog.d.ts +71 -0
- package/dist/orchestration/catalog.d.ts.map +1 -0
- package/dist/orchestration/catalog.js +46 -0
- package/dist/orchestration/catalog.js.map +1 -0
- package/dist/orchestration/compile.d.ts +59 -0
- package/dist/orchestration/compile.d.ts.map +1 -0
- package/dist/orchestration/compile.js +268 -0
- package/dist/orchestration/compile.js.map +1 -0
- package/dist/orchestration/dispatch.d.ts +115 -0
- package/dist/orchestration/dispatch.d.ts.map +1 -0
- package/dist/orchestration/dispatch.js +220 -0
- package/dist/orchestration/dispatch.js.map +1 -0
- package/dist/orchestration/durable-jobs.d.ts +35 -0
- package/dist/orchestration/durable-jobs.d.ts.map +1 -0
- package/dist/orchestration/durable-jobs.js +273 -0
- package/dist/orchestration/durable-jobs.js.map +1 -0
- package/dist/orchestration/effect-key.d.ts +23 -0
- package/dist/orchestration/effect-key.d.ts.map +1 -0
- package/dist/orchestration/effect-key.js +24 -0
- package/dist/orchestration/effect-key.js.map +1 -0
- package/dist/orchestration/evidence-store.d.ts +75 -0
- package/dist/orchestration/evidence-store.d.ts.map +1 -0
- package/dist/orchestration/evidence-store.js +183 -0
- package/dist/orchestration/evidence-store.js.map +1 -0
- package/dist/orchestration/hash.d.ts +12 -0
- package/dist/orchestration/hash.d.ts.map +1 -0
- package/dist/orchestration/hash.js +43 -0
- package/dist/orchestration/hash.js.map +1 -0
- package/dist/orchestration/host.d.ts +121 -0
- package/dist/orchestration/host.d.ts.map +1 -0
- package/dist/orchestration/host.js +334 -0
- package/dist/orchestration/host.js.map +1 -0
- package/dist/orchestration/index.d.ts +20 -0
- package/dist/orchestration/index.d.ts.map +1 -0
- package/dist/orchestration/index.js +19 -0
- package/dist/orchestration/index.js.map +1 -0
- package/dist/orchestration/policy.d.ts +63 -0
- package/dist/orchestration/policy.d.ts.map +1 -0
- package/dist/orchestration/policy.js +80 -0
- package/dist/orchestration/policy.js.map +1 -0
- package/dist/orchestration/recovery.d.ts +87 -0
- package/dist/orchestration/recovery.d.ts.map +1 -0
- package/dist/orchestration/recovery.js +197 -0
- package/dist/orchestration/recovery.js.map +1 -0
- package/dist/orchestration/tracer.d.ts +31 -0
- package/dist/orchestration/tracer.d.ts.map +1 -0
- package/dist/orchestration/tracer.js +141 -0
- package/dist/orchestration/tracer.js.map +1 -0
- package/dist/plans.d.ts +35 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +63 -0
- package/dist/plans.js.map +1 -0
- package/dist/presets.d.ts +10 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +70 -0
- package/dist/presets.js.map +1 -0
- package/dist/process.d.ts +6 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +42 -0
- package/dist/process.js.map +1 -0
- package/dist/registry.d.ts +37 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +51 -0
- package/dist/registry.js.map +1 -0
- package/dist/replay.d.ts +24 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +154 -0
- package/dist/replay.js.map +1 -0
- package/dist/rpc.d.ts +51 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +412 -0
- package/dist/rpc.js.map +1 -0
- package/dist/session-migration.d.ts +59 -0
- package/dist/session-migration.d.ts.map +1 -0
- package/dist/session-migration.js +315 -0
- package/dist/session-migration.js.map +1 -0
- package/dist/sessions.d.ts +74 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +222 -0
- package/dist/sessions.js.map +1 -0
- package/dist/subagents.d.ts +51 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +141 -0
- package/dist/subagents.js.map +1 -0
- package/dist/tools.d.ts +13 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +37 -0
- package/dist/tools.js.map +1 -0
- package/dist/ui.d.ts +42 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +93 -0
- package/dist/ui.js.map +1 -0
- package/dist/verification.d.ts +34 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +91 -0
- package/dist/verification.js.map +1 -0
- package/dist/workflows.d.ts +98 -0
- package/dist/workflows.d.ts.map +1 -0
- package/dist/workflows.js +174 -0
- package/dist/workflows.js.map +1 -0
- package/package.json +84 -0
- package/src/approvals.ts +249 -0
- package/src/capabilities.ts +74 -0
- package/src/cli.ts +405 -0
- package/src/context.ts +81 -0
- package/src/deployment-adapters.ts +181 -0
- package/src/diff.ts +80 -0
- package/src/errors.ts +46 -0
- package/src/extensions.ts +402 -0
- package/src/handoffs.ts +299 -0
- package/src/healing.ts +93 -0
- package/src/host.ts +465 -0
- package/src/index.ts +165 -0
- package/src/isolated-worker.ts +177 -0
- package/src/isolated.ts +344 -0
- package/src/native.ts +478 -0
- package/src/orchestration/artifact-port.ts +75 -0
- package/src/orchestration/catalog.ts +102 -0
- package/src/orchestration/compile.ts +373 -0
- package/src/orchestration/dispatch.ts +383 -0
- package/src/orchestration/durable-jobs.ts +374 -0
- package/src/orchestration/effect-key.ts +35 -0
- package/src/orchestration/evidence-store.ts +233 -0
- package/src/orchestration/hash.ts +43 -0
- package/src/orchestration/host.ts +445 -0
- package/src/orchestration/index.ts +131 -0
- package/src/orchestration/policy.ts +116 -0
- package/src/orchestration/recovery.ts +271 -0
- package/src/orchestration/tracer.ts +177 -0
- package/src/plans.ts +105 -0
- package/src/presets.ts +90 -0
- package/src/process.ts +47 -0
- package/src/registry.ts +80 -0
- package/src/replay.ts +190 -0
- package/src/rpc.ts +598 -0
- package/src/session-migration.ts +401 -0
- package/src/sessions.ts +303 -0
- package/src/subagents.ts +210 -0
- package/src/tools.ts +52 -0
- package/src/ui.ts +137 -0
- package/src/verification.ts +133 -0
- package/src/workflows.ts +279 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truthful public deployment reference profiles.
|
|
3
|
+
*
|
|
4
|
+
* These adapters are deterministic lifecycle fixtures. They do not start a process, connect to a
|
|
5
|
+
* network, distribute secrets, or isolate hostile code. A real deployment implementation belongs
|
|
6
|
+
* behind the public `AgentDeploymentAdapter` contract and must provide its own security proof.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type AgentDeploymentAdapter,
|
|
11
|
+
type DeploymentCancelRequest,
|
|
12
|
+
type DeploymentCancelResult,
|
|
13
|
+
type DeploymentCapabilityReport,
|
|
14
|
+
type DeploymentDisposeRequest,
|
|
15
|
+
type DeploymentDisposeResult,
|
|
16
|
+
DeploymentError,
|
|
17
|
+
type DeploymentInspection,
|
|
18
|
+
type DeploymentInspectRequest,
|
|
19
|
+
type DeploymentPrepareRequest,
|
|
20
|
+
type DeploymentPrepareResult,
|
|
21
|
+
type DeploymentStartRequest,
|
|
22
|
+
type DeploymentStartResult,
|
|
23
|
+
parseDeploymentCapabilityReport,
|
|
24
|
+
} from "@nifrajs/agent"
|
|
25
|
+
|
|
26
|
+
type ReferenceState = "prepared" | "running" | "cancelled" | "disposed"
|
|
27
|
+
|
|
28
|
+
const LOCAL_PROCESS_CAPABILITIES: DeploymentCapabilityReport = Object.freeze({
|
|
29
|
+
schemaVersion: 1,
|
|
30
|
+
adapterId: "local-process",
|
|
31
|
+
capabilities: Object.freeze({
|
|
32
|
+
runtime: "local",
|
|
33
|
+
network: "none",
|
|
34
|
+
filesystem: "workspace",
|
|
35
|
+
process: "child",
|
|
36
|
+
secrets: "none",
|
|
37
|
+
workspace: Object.freeze({ mode: "scoped", maxBytes: 10_000_000_000 }),
|
|
38
|
+
cancellation: "cooperative",
|
|
39
|
+
hostileCodeIsolation: "none",
|
|
40
|
+
}),
|
|
41
|
+
limitations: Object.freeze(["not-a-sandbox", "trusted-local"]),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const CI_CAPABILITIES: DeploymentCapabilityReport = Object.freeze({
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
adapterId: "ci",
|
|
47
|
+
capabilities: Object.freeze({
|
|
48
|
+
runtime: "ci",
|
|
49
|
+
network: "none",
|
|
50
|
+
filesystem: "workspace",
|
|
51
|
+
process: "child",
|
|
52
|
+
secrets: "none",
|
|
53
|
+
workspace: Object.freeze({ mode: "scoped", maxBytes: 10_000_000_000 }),
|
|
54
|
+
cancellation: "cooperative",
|
|
55
|
+
hostileCodeIsolation: "none",
|
|
56
|
+
}),
|
|
57
|
+
limitations: Object.freeze(["not-a-sandbox", "ephemeral"]),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const REPLAY_CAPABILITIES: DeploymentCapabilityReport = Object.freeze({
|
|
61
|
+
schemaVersion: 1,
|
|
62
|
+
adapterId: "replay",
|
|
63
|
+
capabilities: Object.freeze({
|
|
64
|
+
runtime: "replay",
|
|
65
|
+
network: "none",
|
|
66
|
+
filesystem: "none",
|
|
67
|
+
process: "none",
|
|
68
|
+
secrets: "none",
|
|
69
|
+
workspace: Object.freeze({ mode: "none", maxBytes: 0 }),
|
|
70
|
+
cancellation: "cooperative",
|
|
71
|
+
hostileCodeIsolation: "none",
|
|
72
|
+
}),
|
|
73
|
+
limitations: Object.freeze(["not-a-sandbox", "no-side-effects"]),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
abstract class DeterministicDeploymentAdapter implements AgentDeploymentAdapter {
|
|
77
|
+
readonly id: string
|
|
78
|
+
private readonly report: DeploymentCapabilityReport
|
|
79
|
+
private readonly states = new Map<string, ReferenceState>()
|
|
80
|
+
|
|
81
|
+
protected constructor(report: DeploymentCapabilityReport) {
|
|
82
|
+
this.report = parseDeploymentCapabilityReport(report)
|
|
83
|
+
this.id = this.report.adapterId
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
capabilityReport(): DeploymentCapabilityReport {
|
|
87
|
+
return this.report
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
prepare(request: DeploymentPrepareRequest): DeploymentPrepareResult {
|
|
91
|
+
this.assertActive(request.signal)
|
|
92
|
+
const deploymentId = request.plan.deploymentId
|
|
93
|
+
if (this.states.has(deploymentId)) throw new DeploymentError("invalid_transition")
|
|
94
|
+
this.states.set(deploymentId, "prepared")
|
|
95
|
+
return Object.freeze({
|
|
96
|
+
deploymentId,
|
|
97
|
+
state: "prepared",
|
|
98
|
+
preparedRef: `${deploymentId}:prepared`,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
start(request: DeploymentStartRequest): DeploymentStartResult {
|
|
103
|
+
this.assertActive(request.signal)
|
|
104
|
+
if (
|
|
105
|
+
this.states.get(request.deploymentId) !== "prepared" ||
|
|
106
|
+
request.preparedRef !== `${request.deploymentId}:prepared`
|
|
107
|
+
)
|
|
108
|
+
throw new DeploymentError("invalid_transition")
|
|
109
|
+
this.states.set(request.deploymentId, "running")
|
|
110
|
+
return Object.freeze({
|
|
111
|
+
deploymentId: request.deploymentId,
|
|
112
|
+
state: "running",
|
|
113
|
+
handleRef: `${request.deploymentId}:handle`,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
inspect(request: DeploymentInspectRequest): DeploymentInspection {
|
|
118
|
+
this.assertActive(request.signal)
|
|
119
|
+
const state = this.states.get(request.deploymentId)
|
|
120
|
+
if (state === undefined) throw new DeploymentError("invalid_transition")
|
|
121
|
+
return Object.freeze({
|
|
122
|
+
deploymentId: request.deploymentId,
|
|
123
|
+
state,
|
|
124
|
+
...(state === "running" ? { handleRef: `${request.deploymentId}:handle` } : {}),
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
cancel(request: DeploymentCancelRequest): DeploymentCancelResult {
|
|
129
|
+
this.assertActive(request.signal)
|
|
130
|
+
const state = this.states.get(request.deploymentId)
|
|
131
|
+
if (state !== "prepared" && state !== "running") throw new DeploymentError("invalid_transition")
|
|
132
|
+
this.states.set(request.deploymentId, "cancelled")
|
|
133
|
+
return Object.freeze({ deploymentId: request.deploymentId, state: "cancelled" })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
dispose(request: DeploymentDisposeRequest): DeploymentDisposeResult {
|
|
137
|
+
this.assertActive(request.signal)
|
|
138
|
+
this.states.set(request.deploymentId, "disposed")
|
|
139
|
+
return Object.freeze({ deploymentId: request.deploymentId, state: "disposed" })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private assertActive(signal: AbortSignal): void {
|
|
143
|
+
if (signal.aborted) throw new DeploymentError("cancelled")
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class LocalProcessDeploymentAdapter extends DeterministicDeploymentAdapter {
|
|
148
|
+
constructor() {
|
|
149
|
+
super(LOCAL_PROCESS_CAPABILITIES)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export class CiDeploymentAdapter extends DeterministicDeploymentAdapter {
|
|
154
|
+
constructor() {
|
|
155
|
+
super(CI_CAPABILITIES)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export class ReplayDeploymentAdapter extends DeterministicDeploymentAdapter {
|
|
160
|
+
constructor() {
|
|
161
|
+
super(REPLAY_CAPABILITIES)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function createLocalProcessDeploymentAdapter(): LocalProcessDeploymentAdapter {
|
|
166
|
+
return new LocalProcessDeploymentAdapter()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createCiDeploymentAdapter(): CiDeploymentAdapter {
|
|
170
|
+
return new CiDeploymentAdapter()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createReplayDeploymentAdapter(): ReplayDeploymentAdapter {
|
|
174
|
+
return new ReplayDeploymentAdapter()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const DEPLOYMENT_REFERENCE_PROFILES = Object.freeze({
|
|
178
|
+
localProcess: LOCAL_PROCESS_CAPABILITIES,
|
|
179
|
+
ci: CI_CAPABILITIES,
|
|
180
|
+
replay: REPLAY_CAPABILITIES,
|
|
181
|
+
})
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { publicErrorDetails } from "./errors.ts"
|
|
2
|
+
import { readBoundedText } from "./process.ts"
|
|
3
|
+
|
|
4
|
+
export interface ProjectDiffOptions {
|
|
5
|
+
readonly cwd: string
|
|
6
|
+
readonly timeoutMs?: number
|
|
7
|
+
readonly maxOutputBytes?: number
|
|
8
|
+
readonly exposeErrorStacks?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ProjectDiffResult {
|
|
12
|
+
readonly ok: boolean
|
|
13
|
+
readonly status: number | null
|
|
14
|
+
readonly output?: string
|
|
15
|
+
readonly truncated?: boolean
|
|
16
|
+
readonly error?: string
|
|
17
|
+
readonly stack?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read a bounded, non-interactive git diff for review surfaces. No user-supplied git arguments are accepted. */
|
|
21
|
+
export async function readProjectDiff(options: ProjectDiffOptions): Promise<ProjectDiffResult> {
|
|
22
|
+
const timeoutMs = options.timeoutMs ?? 30_000
|
|
23
|
+
const maxOutputBytes = options.maxOutputBytes ?? 512 * 1024
|
|
24
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10 * 60_000)
|
|
25
|
+
throw new RangeError("project diff: timeoutMs is invalid")
|
|
26
|
+
if (
|
|
27
|
+
!Number.isSafeInteger(maxOutputBytes) ||
|
|
28
|
+
maxOutputBytes < 1024 ||
|
|
29
|
+
maxOutputBytes > 16 * 1024 * 1024
|
|
30
|
+
)
|
|
31
|
+
throw new RangeError("project diff: maxOutputBytes is invalid")
|
|
32
|
+
const proc = Bun.spawn(
|
|
33
|
+
["git", "--no-pager", "diff", "HEAD", "--no-ext-diff", "--unified=3", "--"],
|
|
34
|
+
{
|
|
35
|
+
cwd: options.cwd,
|
|
36
|
+
stdin: "ignore",
|
|
37
|
+
stdout: "pipe",
|
|
38
|
+
stderr: "pipe",
|
|
39
|
+
env: safeEnv(),
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
let timedOut = false
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
timedOut = true
|
|
45
|
+
proc.kill()
|
|
46
|
+
}, timeoutMs)
|
|
47
|
+
try {
|
|
48
|
+
const [stdout, stderr, status] = await Promise.all([
|
|
49
|
+
readBoundedText(proc.stdout, maxOutputBytes),
|
|
50
|
+
readBoundedText(proc.stderr, Math.min(maxOutputBytes, 64 * 1024)),
|
|
51
|
+
proc.exited,
|
|
52
|
+
])
|
|
53
|
+
return {
|
|
54
|
+
ok: !timedOut && status === 0,
|
|
55
|
+
status,
|
|
56
|
+
output: stdout.text.length > 0 ? stdout.text : stderr.text,
|
|
57
|
+
...(stdout.truncated ? { truncated: true } : {}),
|
|
58
|
+
...(timedOut ? { error: `git diff timed out after ${timeoutMs}ms` } : {}),
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const details = publicErrorDetails(error, "git diff failed", options.exposeErrorStacks === true)
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
status: null,
|
|
65
|
+
error: details.message,
|
|
66
|
+
...(details.stack === undefined ? {} : { stack: details.stack }),
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timer)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function safeEnv(): Record<string, string> {
|
|
74
|
+
const result: Record<string, string> = {}
|
|
75
|
+
for (const name of ["PATH", "HOME", "LANG", "LC_ALL", "TERM", "GIT_PAGER"]) {
|
|
76
|
+
const value = name === "GIT_PAGER" ? "cat" : process.env[name]
|
|
77
|
+
if (value !== undefined) result[name] = value
|
|
78
|
+
}
|
|
79
|
+
return result
|
|
80
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const MAX_PUBLIC_ERROR_LENGTH = 4096
|
|
2
|
+
const MAX_PUBLIC_STACK_LENGTH = 16 * 1024
|
|
3
|
+
|
|
4
|
+
export interface PublicErrorDetails {
|
|
5
|
+
readonly message: string
|
|
6
|
+
readonly stack?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Return the useful diagnostic without serializing an Error object or its stack. */
|
|
10
|
+
export function publicErrorMessage(error: unknown, fallback: string): string {
|
|
11
|
+
const message =
|
|
12
|
+
error instanceof Error
|
|
13
|
+
? error.message
|
|
14
|
+
: typeof error === "string"
|
|
15
|
+
? error
|
|
16
|
+
: error !== null && typeof error === "object" && "message" in error
|
|
17
|
+
? (error as { readonly message?: unknown }).message
|
|
18
|
+
: undefined
|
|
19
|
+
if (typeof message !== "string" || message.length === 0) return fallback
|
|
20
|
+
return message.length > MAX_PUBLIC_ERROR_LENGTH
|
|
21
|
+
? `${message.slice(0, MAX_PUBLIC_ERROR_LENGTH)}…`
|
|
22
|
+
: message
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Add a bounded stack only for an explicitly trusted local diagnostics channel. */
|
|
26
|
+
export function publicErrorDetails(
|
|
27
|
+
error: unknown,
|
|
28
|
+
fallback: string,
|
|
29
|
+
includeStack = false,
|
|
30
|
+
): PublicErrorDetails {
|
|
31
|
+
const message = publicErrorMessage(error, fallback)
|
|
32
|
+
if (!includeStack || !(error instanceof Error) || typeof error.stack !== "string")
|
|
33
|
+
return { message }
|
|
34
|
+
const stack = error.stack
|
|
35
|
+
return {
|
|
36
|
+
message,
|
|
37
|
+
...(stack.length === 0
|
|
38
|
+
? {}
|
|
39
|
+
: {
|
|
40
|
+
stack:
|
|
41
|
+
stack.length > MAX_PUBLIC_STACK_LENGTH
|
|
42
|
+
? `${stack.slice(0, MAX_PUBLIC_STACK_LENGTH)}…`
|
|
43
|
+
: stack,
|
|
44
|
+
}),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs"
|
|
2
|
+
import { join, relative, resolve } from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
type WorkflowEvent,
|
|
5
|
+
type WorkflowResult,
|
|
6
|
+
WorkflowRunner,
|
|
7
|
+
type WorkflowStep,
|
|
8
|
+
} from "./workflows.ts"
|
|
9
|
+
|
|
10
|
+
export interface ExtensionContext {
|
|
11
|
+
readonly cwd: string
|
|
12
|
+
readonly registerCommand: (name: string, handler: ExtensionCommand) => void
|
|
13
|
+
readonly registerTool: (tool: ExtensionTool) => void
|
|
14
|
+
readonly registerWorkflow: (name: string, workflow: ExtensionWorkflow) => void
|
|
15
|
+
readonly registerSubagent: (role: ExtensionSubagent) => void
|
|
16
|
+
readonly registerProvider: (provider: ExtensionProvider) => void
|
|
17
|
+
readonly on: (event: string, handler: ExtensionEventHandler) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ExtensionCommand = (
|
|
21
|
+
args: string,
|
|
22
|
+
context: ExtensionContext,
|
|
23
|
+
) => unknown | PromiseLike<unknown>
|
|
24
|
+
|
|
25
|
+
export interface ExtensionTool {
|
|
26
|
+
readonly name: string
|
|
27
|
+
readonly description: string
|
|
28
|
+
readonly capabilities?: readonly string[]
|
|
29
|
+
readonly execute: (input: unknown, context: ExtensionContext) => unknown | PromiseLike<unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ExtensionSubagent {
|
|
33
|
+
readonly name: string
|
|
34
|
+
readonly description: string
|
|
35
|
+
readonly prompt: string
|
|
36
|
+
readonly capabilities?: readonly string[]
|
|
37
|
+
readonly maxDepth?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ExtensionProvider {
|
|
41
|
+
readonly name: string
|
|
42
|
+
readonly description: string
|
|
43
|
+
readonly capabilities?: readonly string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ExtensionEventHandler = (
|
|
47
|
+
payload: unknown,
|
|
48
|
+
context: ExtensionContext,
|
|
49
|
+
) => unknown | PromiseLike<unknown>
|
|
50
|
+
|
|
51
|
+
/** A workflow factory keeps orchestration lazy and lets every extension fully customize its steps. */
|
|
52
|
+
export type ExtensionWorkflow = (
|
|
53
|
+
context: ExtensionContext,
|
|
54
|
+
) => WorkflowStep | PromiseLike<WorkflowStep>
|
|
55
|
+
|
|
56
|
+
export interface WorkflowRunOptions {
|
|
57
|
+
readonly signal?: AbortSignal
|
|
58
|
+
readonly maxSteps?: number
|
|
59
|
+
readonly maxDepth?: number
|
|
60
|
+
readonly onEvent?: (event: WorkflowEvent) => void | PromiseLike<void>
|
|
61
|
+
readonly exposeErrorStacks?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CodingAgentExtension {
|
|
65
|
+
readonly id: string
|
|
66
|
+
readonly path: string
|
|
67
|
+
readonly capabilities: readonly string[]
|
|
68
|
+
readonly dispose?: () => void | PromiseLike<void>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ExtensionReloadResult {
|
|
72
|
+
readonly revision: string
|
|
73
|
+
readonly loaded: readonly string[]
|
|
74
|
+
readonly disabled: readonly string[]
|
|
75
|
+
readonly rolledBack: boolean
|
|
76
|
+
readonly error?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ExtensionHostOptions {
|
|
80
|
+
readonly cwd: string
|
|
81
|
+
readonly roots: readonly string[]
|
|
82
|
+
readonly trustedCapabilities?: readonly string[]
|
|
83
|
+
/** Optional fast syntax/type/build gate that runs before a module is staged. */
|
|
84
|
+
readonly validate?: (path: string) => void | PromiseLike<void>
|
|
85
|
+
readonly onError?: (error: { readonly path: string; readonly error: unknown }) => void
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Discover only project-local extension files; no home-directory or dependency scan is implicit. */
|
|
89
|
+
export async function discoverExtensions(cwd: string): Promise<readonly string[]> {
|
|
90
|
+
const directory = resolve(cwd, ".nifra/extensions")
|
|
91
|
+
if (!existsSync(directory)) return Object.freeze([])
|
|
92
|
+
const roots: string[] = []
|
|
93
|
+
for await (const path of new Bun.Glob("**/*").scan({ cwd: directory, dot: false })) {
|
|
94
|
+
if (/\.(?:ts|tsx|js|jsx)$/.test(path)) roots.push(join(".nifra/extensions", path))
|
|
95
|
+
}
|
|
96
|
+
return Object.freeze(roots.sort())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Parse an extension without activating it. This is a fast syntax gate before staging. */
|
|
100
|
+
export async function validateExtensionModule(path: string): Promise<void> {
|
|
101
|
+
const source = await Bun.file(path).text()
|
|
102
|
+
const loader = /\.tsx?$/.test(path) ? "tsx" : "js"
|
|
103
|
+
try {
|
|
104
|
+
new Bun.Transpiler({ loader }).transformSync(source)
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`extension validation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type ExtensionFactory = (
|
|
113
|
+
context: ExtensionContext,
|
|
114
|
+
) =>
|
|
115
|
+
| void
|
|
116
|
+
| { readonly dispose?: () => void | PromiseLike<void> }
|
|
117
|
+
| PromiseLike<void | { readonly dispose?: () => void | PromiseLike<void> }>
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Transactional TypeScript extension loader for the Nifra-native backend.
|
|
121
|
+
*
|
|
122
|
+
* This loader only activates explicitly supplied files. It never scans a project implicitly and it
|
|
123
|
+
* leaves the previous extension graph intact when a staged reload fails.
|
|
124
|
+
*/
|
|
125
|
+
export class ExtensionHost {
|
|
126
|
+
private readonly options: ExtensionHostOptions
|
|
127
|
+
private active: readonly CodingAgentExtension[] = []
|
|
128
|
+
private commands = new Map<string, ExtensionCommand>()
|
|
129
|
+
private tools = new Map<string, ExtensionTool>()
|
|
130
|
+
private workflows = new Map<string, ExtensionWorkflow>()
|
|
131
|
+
private subagents = new Map<string, ExtensionSubagent>()
|
|
132
|
+
private providers = new Map<string, ExtensionProvider>()
|
|
133
|
+
private handlers = new Map<string, ExtensionEventHandler[]>()
|
|
134
|
+
private revision = "0"
|
|
135
|
+
|
|
136
|
+
constructor(options: ExtensionHostOptions) {
|
|
137
|
+
this.options = Object.freeze({
|
|
138
|
+
...options,
|
|
139
|
+
roots: Object.freeze([...options.roots]),
|
|
140
|
+
trustedCapabilities: Object.freeze([...(options.trustedCapabilities ?? [])]),
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get extensions(): readonly CodingAgentExtension[] {
|
|
145
|
+
return this.active
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
get currentRevision(): string {
|
|
149
|
+
return this.revision
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
get availableCommands(): readonly string[] {
|
|
153
|
+
return Object.freeze([...this.commands.keys()].sort())
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
get availableTools(): readonly ExtensionTool[] {
|
|
157
|
+
return Object.freeze([...this.tools.values()])
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
get availableWorkflows(): readonly string[] {
|
|
161
|
+
return Object.freeze([...this.workflows.keys()].sort())
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get availableSubagents(): readonly ExtensionSubagent[] {
|
|
165
|
+
return Object.freeze([...this.subagents.values()])
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
get availableProviders(): readonly ExtensionProvider[] {
|
|
169
|
+
return Object.freeze([...this.providers.values()])
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async reload(): Promise<ExtensionReloadResult> {
|
|
173
|
+
let pathError: string | undefined
|
|
174
|
+
const paths = this.options.roots
|
|
175
|
+
.map((root) => resolve(this.options.cwd, root))
|
|
176
|
+
.filter((path) => {
|
|
177
|
+
if (!isWithin(this.options.cwd, path)) {
|
|
178
|
+
pathError = `extension path escapes project root: ${path}`
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
if (!existsSync(path)) return false
|
|
182
|
+
try {
|
|
183
|
+
if (!isWithin(this.options.cwd, realpathSync(path))) {
|
|
184
|
+
pathError = `extension path escapes project root: ${path}`
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
return true
|
|
188
|
+
} catch {
|
|
189
|
+
pathError = `extension path could not be resolved: ${path}`
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
const next: CodingAgentExtension[] = []
|
|
194
|
+
const nextCommands = new Map<string, ExtensionCommand>()
|
|
195
|
+
const nextTools = new Map<string, ExtensionTool>()
|
|
196
|
+
const nextWorkflows = new Map<string, ExtensionWorkflow>()
|
|
197
|
+
const nextSubagents = new Map<string, ExtensionSubagent>()
|
|
198
|
+
const nextProviders = new Map<string, ExtensionProvider>()
|
|
199
|
+
const nextHandlers = new Map<string, ExtensionEventHandler[]>()
|
|
200
|
+
try {
|
|
201
|
+
if (pathError !== undefined) throw new Error(pathError)
|
|
202
|
+
for (const path of paths) {
|
|
203
|
+
await this.options.validate?.(path)
|
|
204
|
+
const loaded = await this.loadOne(
|
|
205
|
+
path,
|
|
206
|
+
nextCommands,
|
|
207
|
+
nextTools,
|
|
208
|
+
nextWorkflows,
|
|
209
|
+
nextSubagents,
|
|
210
|
+
nextProviders,
|
|
211
|
+
nextHandlers,
|
|
212
|
+
)
|
|
213
|
+
next.push(loaded)
|
|
214
|
+
}
|
|
215
|
+
const previous = this.active
|
|
216
|
+
this.active = Object.freeze(next)
|
|
217
|
+
this.commands = nextCommands
|
|
218
|
+
this.tools = nextTools
|
|
219
|
+
this.workflows = nextWorkflows
|
|
220
|
+
this.subagents = nextSubagents
|
|
221
|
+
this.providers = nextProviders
|
|
222
|
+
this.handlers = nextHandlers
|
|
223
|
+
this.revision = `${Date.now().toString(36)}-${next.length}`
|
|
224
|
+
for (const extension of previous) {
|
|
225
|
+
try {
|
|
226
|
+
await extension.dispose?.()
|
|
227
|
+
} catch (error) {
|
|
228
|
+
this.options.onError?.({ path: extension.path, error })
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
revision: this.revision,
|
|
233
|
+
loaded: Object.freeze(next.map((extension) => extension.id)),
|
|
234
|
+
disabled: [],
|
|
235
|
+
rolledBack: false,
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
for (const extension of next.reverse()) {
|
|
239
|
+
try {
|
|
240
|
+
await extension.dispose?.()
|
|
241
|
+
} catch {
|
|
242
|
+
/* staged cleanup is best effort */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
246
|
+
this.options.onError?.({ path: paths[next.length] ?? this.options.cwd, error })
|
|
247
|
+
return {
|
|
248
|
+
revision: this.revision,
|
|
249
|
+
loaded: Object.freeze(this.active.map((extension) => extension.id)),
|
|
250
|
+
disabled: Object.freeze(paths.slice(next.length)),
|
|
251
|
+
rolledBack: true,
|
|
252
|
+
error: message,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async close(): Promise<void> {
|
|
258
|
+
const active = this.active
|
|
259
|
+
this.active = []
|
|
260
|
+
this.commands = new Map()
|
|
261
|
+
this.tools = new Map()
|
|
262
|
+
this.workflows = new Map()
|
|
263
|
+
this.subagents = new Map()
|
|
264
|
+
this.providers = new Map()
|
|
265
|
+
this.handlers = new Map()
|
|
266
|
+
for (const extension of active) await extension.dispose?.()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
command(name: string): ExtensionCommand | undefined {
|
|
270
|
+
return this.commands.get(name)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
tool(name: string): ExtensionTool | undefined {
|
|
274
|
+
return this.tools.get(name)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
workflow(name: string): ExtensionWorkflow | undefined {
|
|
278
|
+
return this.workflows.get(name)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
subagent(name: string): ExtensionSubagent | undefined {
|
|
282
|
+
return this.subagents.get(name)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
provider(name: string): ExtensionProvider | undefined {
|
|
286
|
+
return this.providers.get(name)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async runWorkflow(name: string, options: WorkflowRunOptions = {}): Promise<WorkflowResult> {
|
|
290
|
+
const workflow = this.workflows.get(name)
|
|
291
|
+
if (workflow === undefined) throw new Error(`unknown extension workflow: ${name}`)
|
|
292
|
+
const step = await workflow(this.context())
|
|
293
|
+
return new WorkflowRunner(options).run(step)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async emit(event: string, payload: unknown): Promise<void> {
|
|
297
|
+
for (const handler of this.handlers.get(event) ?? []) await handler(payload, this.context())
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async loadOne(
|
|
301
|
+
path: string,
|
|
302
|
+
commands: Map<string, ExtensionCommand>,
|
|
303
|
+
tools: Map<string, ExtensionTool>,
|
|
304
|
+
workflows: Map<string, ExtensionWorkflow>,
|
|
305
|
+
subagents: Map<string, ExtensionSubagent>,
|
|
306
|
+
providers: Map<string, ExtensionProvider>,
|
|
307
|
+
handlers: Map<string, ExtensionEventHandler[]>,
|
|
308
|
+
): Promise<CodingAgentExtension> {
|
|
309
|
+
const module = (await import(`${path}?revision=${Date.now()}-${Math.random()}`)) as {
|
|
310
|
+
default?: ExtensionFactory
|
|
311
|
+
id?: string
|
|
312
|
+
capabilities?: readonly string[]
|
|
313
|
+
}
|
|
314
|
+
if (typeof module.default !== "function")
|
|
315
|
+
throw new Error(`extension ${path} has no default factory`)
|
|
316
|
+
const capabilities = Object.freeze([...(module.capabilities ?? [])])
|
|
317
|
+
const trusted = new Set(this.options.trustedCapabilities ?? [])
|
|
318
|
+
const denied = capabilities.find((capability) => !trusted.has(capability))
|
|
319
|
+
if (denied !== undefined)
|
|
320
|
+
throw new Error(`extension ${path} requires untrusted capability: ${denied}`)
|
|
321
|
+
const context = this.context(commands, tools, workflows, subagents, providers, handlers)
|
|
322
|
+
const result = await module.default(context)
|
|
323
|
+
return Object.freeze({
|
|
324
|
+
id: module.id ?? path,
|
|
325
|
+
path,
|
|
326
|
+
capabilities,
|
|
327
|
+
...(result &&
|
|
328
|
+
typeof result === "object" &&
|
|
329
|
+
"dispose" in result &&
|
|
330
|
+
typeof result.dispose === "function"
|
|
331
|
+
? { dispose: result.dispose }
|
|
332
|
+
: {}),
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private context(
|
|
337
|
+
commands = this.commands,
|
|
338
|
+
tools = this.tools,
|
|
339
|
+
workflows = this.workflows,
|
|
340
|
+
subagents = this.subagents,
|
|
341
|
+
providers = this.providers,
|
|
342
|
+
handlers = this.handlers,
|
|
343
|
+
): ExtensionContext {
|
|
344
|
+
return {
|
|
345
|
+
cwd: this.options.cwd,
|
|
346
|
+
registerCommand: (name, handler) => {
|
|
347
|
+
if (!/^[a-z][a-z0-9._-]{0,63}$/.test(name))
|
|
348
|
+
throw new Error(`invalid extension command: ${name}`)
|
|
349
|
+
if (commands.has(name)) throw new Error(`duplicate extension command: ${name}`)
|
|
350
|
+
commands.set(name, handler)
|
|
351
|
+
},
|
|
352
|
+
registerTool: (tool) => {
|
|
353
|
+
if (!/^[a-z][a-z0-9._-]{0,63}$/.test(tool.name))
|
|
354
|
+
throw new Error(`invalid extension tool: ${tool.name}`)
|
|
355
|
+
if (tools.has(tool.name)) throw new Error(`duplicate extension tool: ${tool.name}`)
|
|
356
|
+
const denied = (tool.capabilities ?? []).find(
|
|
357
|
+
(capability) => !(this.options.trustedCapabilities ?? []).includes(capability),
|
|
358
|
+
)
|
|
359
|
+
if (denied !== undefined)
|
|
360
|
+
throw new Error(`extension tool ${tool.name} requires untrusted capability: ${denied}`)
|
|
361
|
+
tools.set(tool.name, tool)
|
|
362
|
+
},
|
|
363
|
+
registerWorkflow: (name, workflow) => {
|
|
364
|
+
if (!/^[a-z][a-z0-9._-]{0,63}$/.test(name))
|
|
365
|
+
throw new Error(`invalid extension workflow: ${name}`)
|
|
366
|
+
if (workflows.has(name)) throw new Error(`duplicate extension workflow: ${name}`)
|
|
367
|
+
workflows.set(name, workflow)
|
|
368
|
+
},
|
|
369
|
+
registerSubagent: (role) => {
|
|
370
|
+
if (!/^[a-z][a-z0-9._-]{0,63}$/.test(role.name))
|
|
371
|
+
throw new Error(`invalid extension subagent: ${role.name}`)
|
|
372
|
+
if (subagents.has(role.name)) throw new Error(`duplicate extension subagent: ${role.name}`)
|
|
373
|
+
subagents.set(role.name, Object.freeze({ ...role }))
|
|
374
|
+
},
|
|
375
|
+
registerProvider: (provider) => {
|
|
376
|
+
if (!/^[a-z][a-z0-9._-]{0,63}$/.test(provider.name))
|
|
377
|
+
throw new Error(`invalid extension provider: ${provider.name}`)
|
|
378
|
+
if (providers.has(provider.name))
|
|
379
|
+
throw new Error(`duplicate extension provider: ${provider.name}`)
|
|
380
|
+
providers.set(provider.name, Object.freeze({ ...provider }))
|
|
381
|
+
},
|
|
382
|
+
on: (event, handler) => {
|
|
383
|
+
if (!/^[a-z][a-z0-9._-]{0,63}$/.test(event))
|
|
384
|
+
throw new Error(`invalid extension event: ${event}`)
|
|
385
|
+
const current = handlers.get(event) ?? []
|
|
386
|
+
handlers.set(event, [...current, handler])
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
393
|
+
const canonical = (path: string): string => {
|
|
394
|
+
try {
|
|
395
|
+
return realpathSync(path)
|
|
396
|
+
} catch {
|
|
397
|
+
return resolve(path)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const relativePath = relative(canonical(root), canonical(candidate))
|
|
401
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !relativePath.includes("/.."))
|
|
402
|
+
}
|