opencode-secrets-obfuscation 0.1.0 → 0.1.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.
Files changed (104) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1 -31
  3. package/package.json +29 -23
  4. package/plugin.mjs +3841 -0
  5. package/.editorconfig +0 -12
  6. package/.github/workflows/ci.yml +0 -75
  7. package/.github/workflows/release.yml +0 -50
  8. package/.node-version +0 -1
  9. package/AGENTS.md +0 -23
  10. package/INSTALL.md +0 -104
  11. package/docs/checkpoint.md +0 -68
  12. package/docs/history/1-spec-review.md +0 -233
  13. package/docs/history/10-p0-launch-prerequisites-review.md +0 -186
  14. package/docs/history/11-p0-job-containment-review.md +0 -188
  15. package/docs/history/12-p0-api-client.md +0 -15
  16. package/docs/history/13-p0-status-review.md +0 -122
  17. package/docs/history/14-p0-containment-final-review.md +0 -84
  18. package/docs/history/15-config-source-implementation.md +0 -27
  19. package/docs/history/16-sanitizer-implementation.md +0 -65
  20. package/docs/history/17-store-policy-implementation.md +0 -36
  21. package/docs/history/18-pure-mvp-review.md +0 -182
  22. package/docs/history/19-pure-mvp-re-review.md +0 -142
  23. package/docs/history/2-p0-runtime-prerequisites.md +0 -163
  24. package/docs/history/20-pure-mvp-final-review.md +0 -116
  25. package/docs/history/21-pure-mvp-review-final.md +0 -109
  26. package/docs/history/22-pure-mvp-signoff-review.md +0 -113
  27. package/docs/history/23-pure-mvp-signoff-review.md +0 -75
  28. package/docs/history/24-owned-tools-implementation.md +0 -27
  29. package/docs/history/25-v2-plugin-integration.md +0 -53
  30. package/docs/history/26-owned-tools-review.md +0 -138
  31. package/docs/history/27-owned-tools-final-review.md +0 -93
  32. package/docs/history/28-owned-tools-signoff.md +0 -98
  33. package/docs/history/3-p0-harness-foundation.md +0 -105
  34. package/docs/history/4-p0-source-opening.md +0 -78
  35. package/docs/history/5-qualification-gate-review.md +0 -133
  36. package/docs/history/6-p0-infrastructure-review.md +0 -275
  37. package/docs/history/7-p0-job-containment.md +0 -29
  38. package/docs/history/8-p0-evidence-support.md +0 -52
  39. package/docs/history/9-p0-launch-plan.md +0 -88
  40. package/docs/spec/design.md +0 -111
  41. package/docs/spec/intent.md +0 -81
  42. package/docs/spec/plan.md +0 -78
  43. package/docs/testing.md +0 -157
  44. package/scripts/build-plugin.mjs +0 -84
  45. package/scripts/install-opencode-ci.ps1 +0 -39
  46. package/scripts/install-opencode-ci.sh +0 -27
  47. package/scripts/smoke.mjs +0 -105
  48. package/scripts/verify-package.mjs +0 -18
  49. package/src/README.md +0 -22
  50. package/src/budgets/index.ts +0 -49
  51. package/src/config/README.md +0 -4
  52. package/src/config/options.ts +0 -76
  53. package/src/diagnostics/README.md +0 -4
  54. package/src/opencode/v2/filter.ts +0 -350
  55. package/src/opencode/v2/index.ts +0 -86
  56. package/src/opencode/v2/runtime.ts +0 -207
  57. package/src/opencode/v2/transports/README.md +0 -5
  58. package/src/runtime/http-transport.ts +0 -20
  59. package/src/sanitize/README.md +0 -4
  60. package/src/sanitize/index.ts +0 -454
  61. package/src/sources/README.md +0 -5
  62. package/src/sources/dotenv.ts +0 -133
  63. package/src/sources/native/README.md +0 -5
  64. package/src/store/index.ts +0 -64
  65. package/tests/README.md +0 -26
  66. package/tests/contract/plugin-v2.test.ts +0 -29
  67. package/tests/fixtures/README.md +0 -9
  68. package/tests/fixtures/job-child.mjs +0 -18
  69. package/tests/fixtures/plugins/README.md +0 -8
  70. package/tests/fixtures/process-child.mjs +0 -20
  71. package/tests/fixtures/services/README.md +0 -10
  72. package/tests/integration/components.test.ts +0 -5
  73. package/tests/integration/transparent-runtime.test.ts +0 -97
  74. package/tests/integration/windows-job.test.ts +0 -71
  75. package/tests/p0-host/README.md +0 -24
  76. package/tests/p0-host/linux-job.sh +0 -61
  77. package/tests/p0-host/windows-job.ps1 +0 -51
  78. package/tests/p0-native/README.md +0 -33
  79. package/tests/p0-native/windows-handle-loader.tests.ps1 +0 -191
  80. package/tests/scaffold/repository.test.ts +0 -105
  81. package/tests/smoke/README.md +0 -37
  82. package/tests/smoke/cases.json +0 -17
  83. package/tests/smoke/qualification.json +0 -20
  84. package/tests/smoke/runtime-candidate.json +0 -111
  85. package/tests/support/README.md +0 -32
  86. package/tests/support/child-process.ts +0 -235
  87. package/tests/support/host-api.ts +0 -123
  88. package/tests/support/opencode-environment.ts +0 -239
  89. package/tests/support/qualification.ts +0 -154
  90. package/tests/support/recording-endpoint.ts +0 -242
  91. package/tests/support/run-directory.ts +0 -179
  92. package/tests/support/smoke-cases.ts +0 -289
  93. package/tests/unit/budgets.test.ts +0 -35
  94. package/tests/unit/config.test.ts +0 -40
  95. package/tests/unit/dotenv.test.ts +0 -72
  96. package/tests/unit/filter-size.test.ts +0 -132
  97. package/tests/unit/harness.test.ts +0 -228
  98. package/tests/unit/host-api.test.ts +0 -122
  99. package/tests/unit/opencode-environment.test.ts +0 -161
  100. package/tests/unit/qualification.test.ts +0 -128
  101. package/tests/unit/recording-endpoint.test.ts +0 -188
  102. package/tests/unit/sanitize.test.ts +0 -159
  103. package/tests/unit/smoke-cases.test.ts +0 -20
  104. package/tsconfig.json +0 -20
@@ -1,235 +0,0 @@
1
- import { spawn, type ChildProcess } from "node:child_process"
2
- import { createHash } from "node:crypto"
3
- import { lstat, readFile } from "node:fs/promises"
4
- import { isAbsolute } from "node:path"
5
-
6
- export type ChildErrorCode =
7
- | "INVALID_OPTIONS"
8
- | "EXECUTABLE_MISMATCH"
9
- | "STARTUP_TIMEOUT"
10
- | "TIMEOUT"
11
- | "CANCELLED"
12
- | "OUTPUT_LIMIT"
13
- | "SPAWN_FAILED"
14
- | "EXIT_FAILED"
15
- | "TEARDOWN_FAILED"
16
-
17
- export class ChildProcessError extends Error {
18
- readonly code: ChildErrorCode
19
-
20
- constructor(code: ChildErrorCode) {
21
- super(code)
22
- this.name = "ChildProcessError"
23
- this.code = code
24
- }
25
- }
26
-
27
- export interface ChildOptions {
28
- readonly executable: string
29
- readonly expectedExecutableSha256: string
30
- readonly args: readonly string[]
31
- readonly cwd: string
32
- readonly env: Readonly<Record<string, string>>
33
- readonly allowedEnv: readonly string[]
34
- readonly startupTimeoutMs: number
35
- readonly overallTimeoutMs: number
36
- readonly stdoutLimitBytes?: number
37
- readonly stderrLimitBytes?: number
38
- readonly signal?: AbortSignal
39
- }
40
-
41
- export interface ChildResult {
42
- readonly exitCode: number
43
- readonly signal: NodeJS.Signals | null
44
- readonly stdout: string
45
- readonly stderr: string
46
- readonly cwd: string
47
- }
48
-
49
- const DEFAULT_OUTPUT_LIMIT = 1024 * 1024
50
- const TERM_GRACE_MS = 100
51
- const TEARDOWN_LIMIT_MS = 1_000
52
-
53
- function isObject(value: unknown): value is Record<string, unknown> {
54
- return typeof value === "object" && value !== null && !Array.isArray(value)
55
- }
56
-
57
- function isValid(value: unknown): value is ChildOptions {
58
- if (!isObject(value)) return false
59
- const options = value as Partial<ChildOptions>
60
- if (typeof options.executable !== "string" || !isAbsolute(options.executable)) return false
61
- if (typeof options.expectedExecutableSha256 !== "string" || !/^[0-9a-f]{64}$/i.test(options.expectedExecutableSha256)) return false
62
- if (!Array.isArray(options.args) || options.args.some((arg) => typeof arg !== "string")) return false
63
- if (typeof options.cwd !== "string" || !isAbsolute(options.cwd)) return false
64
- if (!isObject(options.env) || Object.values(options.env).some((item) => typeof item !== "string")) return false
65
- if (!Array.isArray(options.allowedEnv) || options.allowedEnv.some((key) => typeof key !== "string" || key.length === 0 || key.includes("="))) return false
66
- const fold = (key: string): string => process.platform === "win32" ? key.toUpperCase() : key
67
- const allowed = new Set(options.allowedEnv.map(fold))
68
- if (allowed.size !== options.allowedEnv.length) return false
69
- if (Object.keys(options.env).some((key) => !allowed.has(fold(key)) || key.includes("=") || key.length === 0)) return false
70
- const startup = options.startupTimeoutMs
71
- const overall = options.overallTimeoutMs
72
- if (typeof startup !== "number" || !Number.isSafeInteger(startup) || startup <= 0) return false
73
- if (typeof overall !== "number" || !Number.isSafeInteger(overall) || overall <= 0) return false
74
- if (options.stdoutLimitBytes !== undefined && (!Number.isSafeInteger(options.stdoutLimitBytes) || options.stdoutLimitBytes < 0)) return false
75
- if (options.stderrLimitBytes !== undefined && (!Number.isSafeInteger(options.stderrLimitBytes) || options.stderrLimitBytes < 0)) return false
76
- return options.signal === undefined || options.signal instanceof AbortSignal
77
- }
78
-
79
- async function executableMatches(options: ChildOptions): Promise<boolean> {
80
- try {
81
- const stat = await lstat(options.executable)
82
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return false
83
- const digest = createHash("sha256").update(await readFile(options.executable)).digest("hex")
84
- return digest === options.expectedExecutableSha256.toLowerCase()
85
- } catch {
86
- return false
87
- }
88
- }
89
-
90
- export async function runChild(value: unknown): Promise<ChildResult> {
91
- if (!isValid(value)) throw new ChildProcessError("INVALID_OPTIONS")
92
- const options = value
93
- if (options.signal?.aborted) throw new ChildProcessError("CANCELLED")
94
- if (!(await executableMatches(options))) throw new ChildProcessError("EXECUTABLE_MISMATCH")
95
- if (options.signal?.aborted) throw new ChildProcessError("CANCELLED")
96
-
97
- const env = Object.create(null) as Record<string, string>
98
- for (const key of options.allowedEnv) {
99
- const actual = Object.keys(options.env).find((candidate) => process.platform === "win32" ? candidate.toUpperCase() === key.toUpperCase() : candidate === key)
100
- if (actual !== undefined) env[key] = options.env[actual]!
101
- }
102
- const stdoutLimit = options.stdoutLimitBytes ?? DEFAULT_OUTPUT_LIMIT
103
- const stderrLimit = options.stderrLimitBytes ?? DEFAULT_OUTPUT_LIMIT
104
-
105
- return new Promise((resolve, reject) => {
106
- let child: ChildProcess
107
- try {
108
- child = spawn(options.executable, [...options.args], {
109
- cwd: options.cwd,
110
- env,
111
- shell: false,
112
- windowsHide: true,
113
- stdio: ["ignore", "pipe", "pipe"],
114
- })
115
- } catch {
116
- reject(new ChildProcessError("SPAWN_FAILED"))
117
- return
118
- }
119
-
120
- let promiseSettled = false
121
- let terminating = false
122
- let spawned = false
123
- let childClosed = false
124
- let stdoutClosed = false
125
- let stderrClosed = false
126
- let stdoutBytes = 0
127
- let stderrBytes = 0
128
- const stdoutChunks: Buffer[] = []
129
- const stderrChunks: Buffer[] = []
130
- let failure: ChildErrorCode | undefined
131
- let startupTimer: NodeJS.Timeout | undefined
132
- let overallTimer: NodeJS.Timeout | undefined
133
- let escalationTimer: NodeJS.Timeout | undefined
134
- let teardownTimer: NodeJS.Timeout | undefined
135
-
136
- const clearTimers = (): void => {
137
- if (startupTimer) clearTimeout(startupTimer)
138
- if (overallTimer) clearTimeout(overallTimer)
139
- if (escalationTimer) clearTimeout(escalationTimer)
140
- if (teardownTimer) clearTimeout(teardownTimer)
141
- }
142
- const removeAbort = (): void => options.signal?.removeEventListener("abort", onAbort)
143
- const rejectAfterClose = (): void => {
144
- if (promiseSettled || !failure || !childClosed) return
145
- promiseSettled = true
146
- clearTimers()
147
- removeAbort()
148
- reject(new ChildProcessError(failure))
149
- }
150
- const forceAndWait = (): void => {
151
- if (promiseSettled || !terminating) return
152
- try {
153
- if (child.pid !== undefined) child.kill("SIGKILL")
154
- } catch {
155
- // The bounded teardown timer below remains the authoritative outcome.
156
- }
157
- teardownTimer = setTimeout(() => {
158
- if (!promiseSettled) {
159
- promiseSettled = true
160
- clearTimers()
161
- removeAbort()
162
- reject(new ChildProcessError("TEARDOWN_FAILED"))
163
- }
164
- }, TEARDOWN_LIMIT_MS)
165
- }
166
- const beginFailure = (code: ChildErrorCode): void => {
167
- if (promiseSettled || terminating) return
168
- failure = code
169
- terminating = true
170
- clearTimers()
171
- removeAbort()
172
- // This is the PID returned by this spawn call. No process discovery or
173
- // tree-wide signal is implied; a real host needs a Job Object/group.
174
- try {
175
- if (child.pid !== undefined) child.kill()
176
- } catch {
177
- // Escalation and the bounded failure path decide whether teardown was proven.
178
- }
179
- escalationTimer = setTimeout(forceAndWait, TERM_GRACE_MS)
180
- rejectAfterClose()
181
- }
182
- const onAbort = (): void => beginFailure("CANCELLED")
183
-
184
- // Register cancellation immediately before the synchronous spawn call. The
185
- // pre-aborted checks above handle the no-spawn case; this listener owns the
186
- // cancellation race after a child has been successfully created.
187
- if (options.signal) options.signal.addEventListener("abort", onAbort, { once: true })
188
- if (options.signal?.aborted) {
189
- removeAbort()
190
- reject(new ChildProcessError("CANCELLED"))
191
- return
192
- }
193
-
194
- startupTimer = setTimeout(() => beginFailure("STARTUP_TIMEOUT"), options.startupTimeoutMs)
195
- overallTimer = setTimeout(() => beginFailure("TIMEOUT"), options.overallTimeoutMs)
196
- child.once("spawn", () => {
197
- spawned = true
198
- if (startupTimer) clearTimeout(startupTimer)
199
- })
200
- child.once("error", () => beginFailure(spawned ? "EXIT_FAILED" : "SPAWN_FAILED"))
201
- child.stdout?.once("close", () => { stdoutClosed = true; rejectAfterClose() })
202
- child.stderr?.once("close", () => { stderrClosed = true; rejectAfterClose() })
203
- child.stdout?.on("data", (chunk: Buffer) => {
204
- if (terminating) return
205
- stdoutBytes += chunk.byteLength
206
- if (stdoutBytes > stdoutLimit) {
207
- beginFailure("OUTPUT_LIMIT")
208
- return
209
- }
210
- stdoutChunks.push(chunk)
211
- })
212
- child.stderr?.on("data", (chunk: Buffer) => {
213
- if (terminating) return
214
- stderrBytes += chunk.byteLength
215
- if (stderrBytes > stderrLimit) {
216
- beginFailure("OUTPUT_LIMIT")
217
- return
218
- }
219
- stderrChunks.push(chunk)
220
- })
221
- child.once("close", (exitCode, signal) => {
222
- childClosed = true
223
- if (failure) {
224
- rejectAfterClose()
225
- } else if (exitCode === null) {
226
- beginFailure("EXIT_FAILED")
227
- } else {
228
- promiseSettled = true
229
- clearTimers()
230
- removeAbort()
231
- resolve({ exitCode, signal, stdout: Buffer.concat(stdoutChunks).toString("utf8"), stderr: Buffer.concat(stderrChunks).toString("utf8"), cwd: options.cwd })
232
- }
233
- })
234
- })
235
- }
@@ -1,123 +0,0 @@
1
- import { request } from "node:http"
2
- import { isAbsolute, relative, resolve, sep } from "node:path"
3
-
4
- const MAX_BODY = 64 * 1024
5
- const MAX_HEADERS = 16 * 1024
6
- const DEFAULT_TIMEOUT = 2_000
7
-
8
- export interface HostInfo {
9
- readonly version: string
10
- readonly pid: number
11
- readonly urls: readonly string[]
12
- readonly paths: Readonly<{ readonly tmp: string }>
13
- }
14
-
15
- interface HostApiInput {
16
- readonly origin: string
17
- readonly password: string
18
- readonly expectedVersion: string
19
- readonly expectedPID?: number
20
- readonly expectedTmpRoot: string
21
- readonly timeoutMs?: number
22
- }
23
-
24
- function invalid(): never { throw new Error("HOST_API_INVALID") }
25
-
26
- function fields(value: unknown, required: readonly string[], optional: readonly string[] = []): Record<string, unknown> {
27
- if (typeof value !== "object" || value === null || Array.isArray(value)) invalid()
28
- const prototype = Object.getPrototypeOf(value)
29
- if (prototype !== Object.prototype && prototype !== null) invalid()
30
- const keys = Reflect.ownKeys(value)
31
- if (keys.some((key) => typeof key !== "string" || (!required.includes(key) && !optional.includes(key)))) invalid()
32
- const result: Record<string, unknown> = Object.create(null)
33
- for (const key of keys) {
34
- const descriptor = Object.getOwnPropertyDescriptor(value, key)
35
- if (!descriptor || !("value" in descriptor)) invalid()
36
- result[key as string] = descriptor.value
37
- }
38
- if (required.some((key) => !Object.hasOwn(result, key))) invalid()
39
- return result
40
- }
41
-
42
- function requiredFields(value: unknown, required: readonly string[]): Record<string, unknown> {
43
- if (typeof value !== "object" || value === null || Array.isArray(value)) invalid()
44
- const prototype = Object.getPrototypeOf(value)
45
- if (prototype !== Object.prototype && prototype !== null) invalid()
46
- const result: Record<string, unknown> = Object.create(null)
47
- for (const key of required) {
48
- const descriptor = Object.getOwnPropertyDescriptor(value, key)
49
- if (!descriptor || !("value" in descriptor)) invalid()
50
- result[key] = descriptor.value
51
- }
52
- return result
53
- }
54
-
55
- function parseInput(value: unknown): HostApiInput {
56
- try {
57
- const input = fields(value, ["origin", "password", "expectedVersion", "expectedTmpRoot"], ["expectedPID", "timeoutMs"])
58
- if (typeof input.origin !== "string" || !/^http:\/\/127\.0\.0\.1:\d+$/.test(input.origin)) invalid()
59
- const url = new URL(input.origin)
60
- if (url.origin !== input.origin || url.pathname !== "/" || url.search || url.hash || !url.port) invalid()
61
- if (typeof input.password !== "string" || !/^[a-f0-9]{64}$/.test(input.password)) invalid()
62
- if (typeof input.expectedVersion !== "string" || input.expectedVersion.length === 0 || input.expectedVersion.length > 128) invalid()
63
- if (typeof input.expectedTmpRoot !== "string" || !isAbsolute(input.expectedTmpRoot) || input.expectedTmpRoot.length > 4_096) invalid()
64
- if (input.expectedPID !== undefined && (typeof input.expectedPID !== "number" || !Number.isSafeInteger(input.expectedPID) || input.expectedPID < 0)) invalid()
65
- if (input.timeoutMs !== undefined && (typeof input.timeoutMs !== "number" || !Number.isInteger(input.timeoutMs) || input.timeoutMs < 1 || input.timeoutMs > 10_000)) invalid()
66
- return input as unknown as HostApiInput
67
- } catch (error) {
68
- if (error instanceof Error && error.message === "HOST_API_INVALID") throw error
69
- throw new Error("HOST_API_INVALID")
70
- }
71
- }
72
-
73
- function contained(root: string, value: string): boolean {
74
- const parent = resolve(root)
75
- const child = resolve(value)
76
- const rest = relative(parent, child)
77
- return rest === "" || (rest !== ".." && !rest.startsWith(`..${sep}`) && !isAbsolute(rest))
78
- }
79
-
80
- function decode(body: string, input: HostApiInput): HostInfo {
81
- let value: unknown
82
- try { value = JSON.parse(body) } catch { throw new Error("HOST_API_SCHEMA") }
83
- let data: Record<string, unknown>
84
- try { data = requiredFields(value, ["version", "pid", "urls", "paths"]) } catch { throw new Error("HOST_API_SCHEMA") }
85
- if (typeof data.version !== "string" || data.version !== input.expectedVersion) throw new Error("HOST_API_VERSION")
86
- if (typeof data.pid !== "number" || !Number.isSafeInteger(data.pid) || data.pid < 0) throw new Error("HOST_API_SCHEMA")
87
- if (input.expectedPID !== undefined && data.pid !== input.expectedPID) throw new Error("HOST_API_PID")
88
- if (!Array.isArray(data.urls) || data.urls.some((url) => typeof url !== "string")) throw new Error("HOST_API_SCHEMA")
89
- const urls = data.urls as string[]
90
- for (const advertised of urls) {
91
- try { if (new URL(advertised).origin !== input.origin) throw new Error() } catch { throw new Error("HOST_API_URL") }
92
- }
93
- let paths: Record<string, unknown>
94
- try { paths = requiredFields(data.paths, ["tmp"]) } catch { throw new Error("HOST_API_SCHEMA") }
95
- if (typeof paths.tmp !== "string" || !isAbsolute(paths.tmp) || !contained(input.expectedTmpRoot, paths.tmp)) throw new Error("HOST_API_TMP_ROOT")
96
- return Object.freeze({ version: data.version, pid: data.pid, urls: Object.freeze([...urls]), paths: Object.freeze({ tmp: paths.tmp }) })
97
- }
98
-
99
- export function requestHostInfo(value: unknown): Promise<HostInfo> {
100
- let input: HostApiInput
101
- try { input = parseInput(value) } catch (error) { return Promise.reject(error instanceof Error && error.message === "HOST_API_INVALID" ? error : new Error("HOST_API_INVALID")) }
102
- return new Promise((resolveResult, reject) => {
103
- let settled = false
104
- const finish = (error?: Error, result?: HostInfo) => {
105
- if (settled) return
106
- settled = true
107
- if (error) reject(error)
108
- else resolveResult(result as HostInfo)
109
- }
110
- const timer = setTimeout(() => { requestHandle.destroy(); finish(new Error("HOST_API_TIMEOUT")) }, input.timeoutMs ?? DEFAULT_TIMEOUT)
111
- const requestHandle = request({ hostname: "127.0.0.1", port: Number(new URL(input.origin).port), path: "/api/info", method: "GET", auth: `opencode:${input.password}`, maxHeaderSize: MAX_HEADERS }, (response) => {
112
- if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400) { response.destroy(); clearTimeout(timer); finish(new Error("HOST_API_REDIRECT")); return }
113
- if (response.statusCode !== 200) { response.destroy(); clearTimeout(timer); finish(new Error("HOST_API_STATUS")); return }
114
- let size = 0
115
- const chunks: Buffer[] = []
116
- response.on("data", (chunk: Buffer) => { size += chunk.length; if (size > MAX_BODY) { response.destroy(); clearTimeout(timer); finish(new Error("HOST_API_BODY")); } else chunks.push(chunk) })
117
- response.on("end", () => { clearTimeout(timer); try { finish(undefined, decode(Buffer.concat(chunks).toString("utf8"), input)) } catch (error) { finish(error instanceof Error ? error : new Error("HOST_API_SCHEMA")) } })
118
- response.on("error", () => { clearTimeout(timer); finish(new Error("HOST_API_TRANSPORT")) })
119
- })
120
- requestHandle.on("error", () => { clearTimeout(timer); finish(new Error("HOST_API_TRANSPORT")) })
121
- requestHandle.end()
122
- })
123
- }
@@ -1,239 +0,0 @@
1
- import { isAbsolute, join, relative, resolve, sep } from "node:path"
2
-
3
- export interface TrustedOsEnvironment {
4
- readonly userProfile: string
5
- readonly home: string
6
- readonly programData: string
7
- readonly temp: string
8
- readonly tmp: string
9
- readonly path: string
10
- readonly systemRoot: string
11
- readonly comspec: string
12
- readonly pathext: string
13
- }
14
-
15
- export interface OpenCodeEnvironmentInput {
16
- readonly root: string
17
- /** Generate 32 random bytes per run; never source this from user credentials. */
18
- readonly password: string
19
- readonly trustedOs: TrustedOsEnvironment
20
- readonly configContent?: unknown
21
- /**
22
- * Host-neutral config keys (plugins, providers, ...) merged into
23
- * OPENCODE_CONFIG_CONTENT. Deep-validated without evaluating accessors;
24
- * snapshots/warming/update stay locked to the boot defaults.
25
- */
26
- readonly sandboxConfig?: unknown
27
- }
28
-
29
- export interface OpenCodeEnvironment {
30
- readonly root: string
31
- readonly cwd: string
32
- readonly paths: Readonly<{
33
- readonly data: string
34
- readonly cache: string
35
- readonly config: string
36
- readonly state: string
37
- readonly temp: string
38
- readonly database: string
39
- }>
40
- readonly allowedEnv: readonly string[]
41
- readonly env: Readonly<Record<string, string>>
42
- }
43
-
44
- const DEFAULT_CONFIG = {
45
- snapshots: false,
46
- warming: false,
47
- update: "disable",
48
- } as const
49
-
50
- const ALLOWED_ENV = [
51
- "USERPROFILE",
52
- "HOME",
53
- "ProgramData",
54
- "PATH",
55
- "SystemRoot",
56
- "ComSpec",
57
- "PATHEXT",
58
- "TEMP",
59
- "TMP",
60
- "XDG_DATA_HOME",
61
- "XDG_CACHE_HOME",
62
- "XDG_CONFIG_HOME",
63
- "XDG_STATE_HOME",
64
- "OPENCODE_CONFIG_DIR",
65
- "OPENCODE_CONFIG_PROJECT_DISABLE",
66
- "OPENCODE_CONFIG_CONTENT",
67
- "OPENCODE_DB",
68
- "OPENCODE_PASSWORD",
69
- "OPENCODE_DISABLE_MODELS_FETCH",
70
- "OPENCODE_DISABLE_AUTOUPDATE",
71
- "OPENCODE_DISABLE_FILEWATCHER",
72
- "OPENCODE_DISABLE_PROJECT_CONFIG",
73
- ] as const
74
-
75
- function beneath(root: string, value: string): boolean {
76
- if (!isAbsolute(value)) return false
77
- const child = resolve(value)
78
- const parent = resolve(root)
79
- const rest = relative(parent, child)
80
- return rest === "" || (rest !== ".." && !rest.startsWith(`..${sep}`) && !isAbsolute(rest))
81
- }
82
-
83
- function safeEnvironmentValue(value: unknown): value is string {
84
- return typeof value === "string" && value.length > 0 && value.length <= 32_768 && !/[\0\r\n]/.test(value)
85
- }
86
-
87
- function dataFields(value: unknown, required: readonly string[], optional: readonly string[] = []): Record<string, unknown> {
88
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw 0
89
- const prototype = Object.getPrototypeOf(value)
90
- if (prototype !== Object.prototype && prototype !== null) throw 0
91
- const keys = Reflect.ownKeys(value)
92
- if (keys.length > required.length + optional.length) throw 0
93
- const result: Record<string, unknown> = Object.create(null)
94
- for (const key of keys) {
95
- if (typeof key !== "string" || (!required.includes(key) && !optional.includes(key))) throw 0
96
- const descriptor = Object.getOwnPropertyDescriptor(value, key)
97
- if (!descriptor || !("value" in descriptor)) throw 0
98
- result[key] = descriptor.value
99
- }
100
- if (required.some((key) => !Object.hasOwn(result, key))) throw 0
101
- return result
102
- }
103
-
104
- function lockedConfig(value: unknown): Readonly<Record<string, boolean | string>> {
105
- if (value === undefined) return DEFAULT_CONFIG
106
- const values = dataFields(value, [], ["snapshots", "warming", "update"])
107
- for (const [key, actual] of Object.entries(values)) {
108
- const expected = DEFAULT_CONFIG[key as keyof typeof DEFAULT_CONFIG]
109
- if (actual !== expected) throw 0
110
- }
111
- return Object.freeze({ ...DEFAULT_CONFIG })
112
- }
113
-
114
- const SANDBOX_MAX_DEPTH = 16
115
- const SANDBOX_MAX_NODES = 8_192
116
- const SANDBOX_MAX_STRING = 32_768
117
-
118
- /**
119
- * Deep-clones a sandbox config through data descriptors only: accessors are
120
- * never evaluated, cycles, sparse holes, non-finite numbers, oversized strings
121
- * and non-plain containers are rejected, and locked boot keys may only carry
122
- * their locked value. The clone cannot observe later caller mutation, so the
123
- * final JSON.stringify runs on inert data.
124
- */
125
- function sandboxConfig(value: unknown): Record<string, unknown> | undefined {
126
- if (value === undefined) return undefined
127
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw 0
128
- const rootPrototype = Object.getPrototypeOf(value)
129
- if (rootPrototype !== Object.prototype && rootPrototype !== null) throw 0
130
- let nodes = 0
131
- const seen = new WeakSet<object>()
132
- const walk = (input: unknown, depth: number): unknown => {
133
- nodes += 1
134
- if (nodes > SANDBOX_MAX_NODES || depth > SANDBOX_MAX_DEPTH) throw 0
135
- if (input === null || typeof input === "boolean") return input
136
- if (typeof input === "number") {
137
- if (!Number.isFinite(input)) throw 0
138
- return input
139
- }
140
- if (typeof input === "string") {
141
- if (input.length > SANDBOX_MAX_STRING) throw 0
142
- return input
143
- }
144
- if (typeof input !== "object") throw 0
145
- if (seen.has(input)) throw 0
146
- seen.add(input)
147
- if (Array.isArray(input)) {
148
- const result: unknown[] = []
149
- for (let index = 0; index < input.length; index += 1) {
150
- const descriptor = Object.getOwnPropertyDescriptor(input, String(index))
151
- if (!descriptor || !("value" in descriptor)) throw 0
152
- result.push(walk(descriptor.value, depth + 1))
153
- }
154
- return result
155
- }
156
- const prototype = Object.getPrototypeOf(input)
157
- if (prototype !== Object.prototype && prototype !== null) throw 0
158
- const result: Record<string, unknown> = {}
159
- for (const key of Reflect.ownKeys(input)) {
160
- if (typeof key !== "string") throw 0
161
- const descriptor = Object.getOwnPropertyDescriptor(input, key)
162
- if (!descriptor || !("value" in descriptor)) throw 0
163
- const cloned = walk(descriptor.value, depth + 1)
164
- if (depth === 0) {
165
- const expected = DEFAULT_CONFIG[key as keyof typeof DEFAULT_CONFIG]
166
- if (expected !== undefined && cloned !== expected) throw 0
167
- }
168
- result[key] = cloned
169
- }
170
- return result
171
- }
172
- return walk(value, 0) as Record<string, unknown>
173
- }
174
-
175
- export function buildOpenCodeEnvironment(value: unknown): OpenCodeEnvironment {
176
- try {
177
- const input = dataFields(value, ["root", "trustedOs", "password"], ["configContent", "sandboxConfig"])
178
- if (!safeEnvironmentValue(input.root)) throw 0
179
- if (typeof input.password !== "string" || !/^[a-f0-9]{64}$/.test(input.password)) throw 0
180
- const trusted = dataFields(input.trustedOs, ["userProfile", "home", "programData", "temp", "tmp", "path", "systemRoot", "comspec", "pathext"])
181
- if (Object.values(trusted).some((field) => !safeEnvironmentValue(field))) throw 0
182
- if (!isAbsolute(trusted.systemRoot as string) || !isAbsolute(trusted.comspec as string)) throw 0
183
- return buildValidated({ ...input, trustedOs: trusted } as unknown as OpenCodeEnvironmentInput)
184
- } catch {
185
- throw new Error("OPENCODE_ENVIRONMENT_INVALID")
186
- }
187
- }
188
-
189
- function buildValidated(input: OpenCodeEnvironmentInput): OpenCodeEnvironment {
190
- if (!isAbsolute(input.root)) throw new Error("private root must be absolute")
191
- const root = resolve(input.root)
192
- for (const value of Object.values(input.trustedOs)) {
193
- if (!safeEnvironmentValue(value)) throw new Error("invalid trusted OS environment")
194
- }
195
- const paths = {
196
- data: join(root, "data"),
197
- cache: join(root, "cache"),
198
- config: join(root, "config", "opencode"),
199
- state: join(root, "state"),
200
- temp: join(root, "temp"),
201
- database: join(root, "data", "opencode.db"),
202
- }
203
- for (const value of Object.values(paths)) {
204
- if (!beneath(root, value)) throw new Error("OpenCode path escapes private root")
205
- }
206
- for (const value of [input.trustedOs.userProfile, input.trustedOs.home, input.trustedOs.programData, input.trustedOs.temp, input.trustedOs.tmp]) {
207
- if (!beneath(root, value)) throw new Error("trusted OS path escapes private root")
208
- }
209
- const config = { ...sandboxConfig(input.sandboxConfig), ...lockedConfig(input.configContent) }
210
- const env: Record<string, string> = {
211
- USERPROFILE: input.trustedOs.userProfile,
212
- HOME: input.trustedOs.home,
213
- ProgramData: input.trustedOs.programData,
214
- PATH: input.trustedOs.path,
215
- SystemRoot: input.trustedOs.systemRoot,
216
- ComSpec: input.trustedOs.comspec,
217
- PATHEXT: input.trustedOs.pathext,
218
- TEMP: input.trustedOs.temp,
219
- TMP: input.trustedOs.tmp,
220
- XDG_DATA_HOME: paths.data,
221
- XDG_CACHE_HOME: paths.cache,
222
- XDG_CONFIG_HOME: join(root, "config"),
223
- XDG_STATE_HOME: paths.state,
224
- OPENCODE_CONFIG_DIR: paths.config,
225
- OPENCODE_CONFIG_PROJECT_DISABLE: "1",
226
- OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
227
- OPENCODE_DB: paths.database,
228
- OPENCODE_PASSWORD: input.password,
229
- // Names verified against the pinned opencode binary's own strings.
230
- OPENCODE_DISABLE_MODELS_FETCH: "1",
231
- OPENCODE_DISABLE_AUTOUPDATE: "1",
232
- OPENCODE_DISABLE_FILEWATCHER: "1",
233
- OPENCODE_DISABLE_PROJECT_CONFIG: "1",
234
- }
235
- const frozenPaths = Object.freeze(paths)
236
- const allowedEnv = Object.freeze([...ALLOWED_ENV])
237
- const frozenEnv = Object.freeze({ ...env })
238
- return Object.freeze({ root, cwd: join(root, "cwd"), paths: frozenPaths, allowedEnv, env: frozenEnv })
239
- }