opencode-overclock 0.3.0 → 0.5.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/README.md +252 -111
- package/package.json +6 -4
- package/skills/codebase-design/DEEPENING.md +35 -0
- package/skills/codebase-design/DESIGN-IT-TWICE.md +34 -0
- package/skills/codebase-design/SKILL.md +93 -0
- package/skills/diagnosing-bugs/SKILL.md +123 -0
- package/skills/domain-modeling/ADR-FORMAT.md +55 -0
- package/skills/domain-modeling/CONTEXT-FORMAT.md +32 -0
- package/skills/domain-modeling/SKILL.md +102 -0
- package/skills/doubt/SKILL.md +80 -0
- package/skills/grilling/SKILL.md +96 -0
- package/skills/source-discipline/SKILL.md +78 -0
- package/skills/tdd/SKILL.md +87 -0
- package/skills/to-spec/SKILL.md +69 -0
- package/skills/to-spec/SPEC-TEMPLATE.md +50 -0
- package/skills/to-tickets/SKILL.md +74 -0
- package/skills/to-tickets/TICKET-TEMPLATE.md +41 -0
- package/src/bridge.ts +1 -0
- package/src/buddy/companion.ts +104 -5
- package/src/buddy/sprites.ts +4 -4
- package/src/buddy/tui.ts +175 -65
- package/src/core/bridge.ts +34 -0
- package/src/core/lifecycle.ts +67 -0
- package/src/core/policy.ts +128 -0
- package/src/core/summary.ts +33 -0
- package/src/core/types.ts +193 -0
- package/src/features/buddy.ts +1 -2
- package/src/features/guard.ts +421 -37
- package/src/features/index.ts +18 -4
- package/src/features/recovery.ts +153 -0
- package/src/features/safety.ts +147 -0
- package/src/features/sched.ts +183 -89
- package/src/features/tasks.ts +134 -33
- package/src/features/truncator.ts +116 -0
- package/src/features/usage.ts +46 -65
- package/src/features/workflow.ts +256 -0
- package/src/index.ts +96 -67
- package/src/lib/busy.ts +1 -25
- package/src/lib/exec.ts +13 -0
- package/src/lib/inject.ts +10 -56
- package/src/lib/mirror.ts +13 -0
- package/src/lib/probe.ts +1 -15
- package/src/lib/state.ts +10 -39
- package/src/lib/tmux.ts +1 -0
- package/src/lib/ui.ts +208 -0
- package/src/merge.ts +2 -66
- package/src/platform/probe.ts +25 -0
- package/src/platform/process/exec.ts +317 -0
- package/src/platform/process/tmux.ts +60 -0
- package/src/platform/session/busy.ts +33 -0
- package/src/platform/session/inject.ts +89 -0
- package/src/platform/session/notify.ts +20 -0
- package/src/platform/storage/state.ts +99 -0
- package/src/platform/storage/store.ts +61 -0
- package/src/summary.ts +1 -0
- package/src/tools.ts +8 -244
- package/src/tui.ts +57 -186
- package/src/types.ts +1 -73
- package/src/v2/context.ts +470 -0
- package/src/v2/host.ts +120 -0
- package/src/v2/loader.ts +150 -0
- package/src/workflow/agents/codebase-researcher.ts +27 -0
- package/src/workflow/agents/design-explorer.ts +33 -0
- package/src/workflow/agents/doubt-reviewer.ts +26 -0
- package/src/workflow/agents/engineering-coach.ts +23 -0
- package/src/workflow/agents/performance-auditor.ts +29 -0
- package/src/workflow/agents/security-auditor.ts +23 -0
- package/src/workflow/agents/spec-reviewer.ts +15 -0
- package/src/workflow/agents/standards-reviewer.ts +24 -0
- package/src/workflow/agents/test-engineer.ts +28 -0
- package/src/workflow/catalog.ts +210 -0
- package/src/workflow/templates/build.ts +47 -0
- package/src/workflow/templates/define.ts +45 -0
- package/src/workflow/templates/diagnose.ts +58 -0
- package/src/workflow/templates/plan.ts +52 -0
- package/src/workflow/templates/ship.ts +64 -0
- package/src/buddy/reactions.ts +0 -41
- package/src/buddy/types.ts +0 -30
- package/src/config.ts +0 -19
- package/src/features/checkpoints.ts +0 -128
- package/src/features/sandbox.ts +0 -104
- package/src/validate.ts +0 -197
package/src/lib/ui.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { TuiPluginApi, TuiSlotContext } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import { missingSurfaces } from "./probe.ts"
|
|
3
|
+
import type { Store } from "./state.ts"
|
|
4
|
+
|
|
5
|
+
/** Raw `@opentui/solid` node factory. Returns a JSX.Element the host can render. */
|
|
6
|
+
type JsxFactory = (type: string, props?: Record<string, unknown> | null) => unknown
|
|
7
|
+
|
|
8
|
+
type SlotRender = (ctx: TuiSlotContext) => unknown
|
|
9
|
+
|
|
10
|
+
export interface UiCommand {
|
|
11
|
+
/** command palette entry */
|
|
12
|
+
title: string
|
|
13
|
+
/** slash name, without the leading "/" */
|
|
14
|
+
slash?: string
|
|
15
|
+
/** optional slash aliases */
|
|
16
|
+
aliases?: string[]
|
|
17
|
+
/** unique command id; defaults to `overclock.<slash ?? title>` */
|
|
18
|
+
id?: string
|
|
19
|
+
run(dialog?: unknown): void | Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UiNotify {
|
|
23
|
+
message: string
|
|
24
|
+
title?: string
|
|
25
|
+
/** built-in sound name; only played when the terminal is blurred */
|
|
26
|
+
sound?: "default" | "question" | "permission" | "error" | "done" | "subagent_done"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Thin, failure-tolerant facade over `TuiPluginApi`.
|
|
31
|
+
*
|
|
32
|
+
* Two things it buys us. First, *uniform degradation*: every host call is a place the TUI API can
|
|
33
|
+
* drift out from under us, and a throw inside plugin setup takes down every later registration in
|
|
34
|
+
* the same function. Each method here is individually guarded and warns with a consistent label,
|
|
35
|
+
* so one broken surface costs exactly one feature. Second, *disposal by construction*: the host
|
|
36
|
+
* hands back an unsubscribe from `event.on`, an id from `slots.register` (where cleanup is host-managed), and
|
|
37
|
+
* nothing from `setInterval` -- contracts that call sites previously tracked
|
|
38
|
+
* by hand. Listeners and intervals registered through the facade are wired to `lifecycle.onDispose` here.
|
|
39
|
+
*/
|
|
40
|
+
export interface Ui {
|
|
41
|
+
/** escape hatch for surfaces the facade does not wrap yet */
|
|
42
|
+
readonly api: TuiPluginApi
|
|
43
|
+
/** project directory; the root every `Store` path is resolved against */
|
|
44
|
+
readonly directory: string
|
|
45
|
+
/** subscribe to a host event, auto-disposed. Returns the unsubscribe for early removal. */
|
|
46
|
+
readonly on: TuiPluginApi["event"]["on"]
|
|
47
|
+
/** register a palette/slash command, auto-disposed */
|
|
48
|
+
command(cmd: UiCommand): void
|
|
49
|
+
/** desktop notification + sound, fired only when the terminal is blurred */
|
|
50
|
+
notify(input: UiNotify): void
|
|
51
|
+
toast(message: string, variant?: "info" | "success" | "warning" | "error"): void
|
|
52
|
+
/** setInterval, cleared on dispose */
|
|
53
|
+
every(ms: number, fn: () => void): void
|
|
54
|
+
/** read a store defined in `platform/storage/store.ts` (or re-exported via `mirror.ts`) */
|
|
55
|
+
read<T>(store: Store<T>): Promise<T>
|
|
56
|
+
/** as `read`, but `undefined` when the server half has never written the file */
|
|
57
|
+
readOptional<T>(store: Store<T>): Promise<T | undefined>
|
|
58
|
+
/** true when every dot-path resolves to a function on the host api */
|
|
59
|
+
has(...paths: string[]): boolean
|
|
60
|
+
/**
|
|
61
|
+
* Load the host's JSX runtime, enabling `node()`. Separate from `createUi` because
|
|
62
|
+
* `@opentui/solid` is an optional peer and slot rendering is synchronous: a feature that draws
|
|
63
|
+
* must await this up front, and one that does not should not pay for the import.
|
|
64
|
+
*/
|
|
65
|
+
enableJsx(): Promise<void>
|
|
66
|
+
/** create a renderable node. Throws unless `enableJsx()` resolved first. */
|
|
67
|
+
node(type: string, props: Record<string, unknown>): unknown
|
|
68
|
+
/** mount slot renderers. The host owns slot cleanup, so there is nothing to dispose. */
|
|
69
|
+
slots(map: Record<string, SlotRender>): void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function warn(label: string, e: unknown): void {
|
|
73
|
+
console.warn(`[overclock-tui] ${label} failed: ${e}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Run `fn`, swallowing and reporting any throw. Returns undefined on failure. */
|
|
77
|
+
function guard<T>(label: string, fn: () => T): T | undefined {
|
|
78
|
+
try {
|
|
79
|
+
return fn()
|
|
80
|
+
} catch (e) {
|
|
81
|
+
warn(label, e)
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createUi(api: TuiPluginApi): Ui {
|
|
87
|
+
const directory = api.state.path.directory
|
|
88
|
+
let jsx: JsxFactory | undefined
|
|
89
|
+
|
|
90
|
+
/** Best-effort disposer registration -- `lifecycle` itself is a surface that can be missing. */
|
|
91
|
+
const onDispose = (fn: () => void): void => {
|
|
92
|
+
try {
|
|
93
|
+
api.lifecycle.onDispose(async () => fn())
|
|
94
|
+
} catch (e) {
|
|
95
|
+
warn("lifecycle registration", e)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const noop = () => {}
|
|
100
|
+
|
|
101
|
+
// Cast: the host's `on` is generic over the event union, and reproducing that generic on an
|
|
102
|
+
// ordinary function expression is not expressible without naming `Event`, which
|
|
103
|
+
// `@opencode-ai/plugin/tui` does not re-export. The cast keeps full narrowing for callers.
|
|
104
|
+
const on = ((type: string, handler: (event: never) => void) => {
|
|
105
|
+
const unsub = guard(`subscribe ${type}`, () =>
|
|
106
|
+
(api.event.on as (t: string, h: (event: never) => void) => () => void)(type, handler),
|
|
107
|
+
)
|
|
108
|
+
if (!unsub) return noop
|
|
109
|
+
onDispose(unsub)
|
|
110
|
+
return unsub
|
|
111
|
+
}) as TuiPluginApi["event"]["on"]
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
api,
|
|
115
|
+
directory,
|
|
116
|
+
on,
|
|
117
|
+
|
|
118
|
+
command(cmd) {
|
|
119
|
+
const id = cmd.id ?? `overclock.${cmd.slash ?? cmd.title}`
|
|
120
|
+
// `api.command` is deprecated upstream in favour of `keymap.registerLayer({commands,
|
|
121
|
+
// bindings})`, and optional on the api type. Isolated here so that migration is a change
|
|
122
|
+
// to this one method rather than to every feature that registers a command.
|
|
123
|
+
const unregister = guard(`command ${id}`, () =>
|
|
124
|
+
api.command?.register(() => [
|
|
125
|
+
{
|
|
126
|
+
title: cmd.title,
|
|
127
|
+
value: id,
|
|
128
|
+
...(cmd.slash
|
|
129
|
+
? {
|
|
130
|
+
slash: {
|
|
131
|
+
name: cmd.slash,
|
|
132
|
+
...(cmd.aliases ? { aliases: cmd.aliases } : {}),
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
136
|
+
onSelect: async (dialog) => {
|
|
137
|
+
try {
|
|
138
|
+
await cmd.run(dialog)
|
|
139
|
+
} catch (e) {
|
|
140
|
+
warn(`command ${id}`, e)
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
]),
|
|
145
|
+
)
|
|
146
|
+
if (unregister) onDispose(unregister)
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
notify(input) {
|
|
150
|
+
guard("notify", () =>
|
|
151
|
+
api.attention.notify({
|
|
152
|
+
title: input.title ?? "opencode",
|
|
153
|
+
message: input.message,
|
|
154
|
+
notification: { when: "blurred" },
|
|
155
|
+
...(input.sound ? { sound: { name: input.sound, when: "blurred" as const } } : {}),
|
|
156
|
+
}),
|
|
157
|
+
)
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
toast(message, variant) {
|
|
161
|
+
guard("toast", () => api.ui.toast({ message, ...(variant ? { variant } : {}) }))
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
every(ms, fn) {
|
|
165
|
+
const timer = setInterval(() => {
|
|
166
|
+
try {
|
|
167
|
+
fn()
|
|
168
|
+
} catch (e) {
|
|
169
|
+
warn("interval", e)
|
|
170
|
+
}
|
|
171
|
+
}, ms)
|
|
172
|
+
onDispose(() => clearInterval(timer))
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
read(store) {
|
|
176
|
+
return store.read(directory)
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
readOptional(store) {
|
|
180
|
+
return store.readMaybe(directory)
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
has(...paths) {
|
|
184
|
+
return missingSurfaces(api, paths).length === 0
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async enableJsx() {
|
|
188
|
+
// The host must map this specifier to *its own* solid instance. If it does not, the import
|
|
189
|
+
// throws here -- at the caller's await, where it can degrade to "no drawing" -- rather than
|
|
190
|
+
// from inside a slot render where nothing can catch it.
|
|
191
|
+
const mod = (await import("@opentui/solid/jsx-runtime")) as unknown as { jsx: JsxFactory }
|
|
192
|
+
jsx = mod.jsx
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
node(type, props) {
|
|
196
|
+
if (!jsx) throw new Error("ui.node() requires await ui.enableJsx()")
|
|
197
|
+
return jsx(type, props)
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
slots(map) {
|
|
201
|
+
guard("slot registration", () =>
|
|
202
|
+
api.slots.register({
|
|
203
|
+
slots: map as unknown as Parameters<TuiPluginApi["slots"]["register"]>[0]["slots"],
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/merge.ts
CHANGED
|
@@ -1,66 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Rewrite declared tool names appearing inside a description ("reversible via
|
|
6
|
-
* checkpoint_restore", "Kill it with task_kill"). Left alone, a remap leaves the model
|
|
7
|
-
* reading instructions that name a tool it was never offered. Word-anchored so a name
|
|
8
|
-
* that is a substring of a longer identifier is not clobbered.
|
|
9
|
-
*/
|
|
10
|
-
export function renameInText(text: string, rename: Record<string, string>): string {
|
|
11
|
-
let out = text
|
|
12
|
-
for (const [from, to] of Object.entries(rename)) {
|
|
13
|
-
if (from === to) continue
|
|
14
|
-
out = out.replace(new RegExp(`\\b${from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), to)
|
|
15
|
-
}
|
|
16
|
-
return out
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Compose many Partial<Hooks> into one Hooks.
|
|
21
|
-
* - fn hooks: call sequentially, module order. Each sees prior mutations of `output`.
|
|
22
|
-
* - `tool` map: shallow merge. Name collision -> later module wins, warn.
|
|
23
|
-
* - `policy`: declared tool name -> model-visible name, plus tools to withhold. Applied here
|
|
24
|
-
* because every module's tool map funnels through this one merge, so one pass covers the
|
|
25
|
-
* whole surface and a module never has to know a policy exists.
|
|
26
|
-
*/
|
|
27
|
-
export function mergeHooks(parts: Partial<Hooks>[], policy: ToolPolicy = EMPTY_POLICY): Hooks {
|
|
28
|
-
const merged: Record<string, unknown> = {}
|
|
29
|
-
const tools: Record<string, unknown> = {}
|
|
30
|
-
const { rename, withheld } = policy
|
|
31
|
-
|
|
32
|
-
for (const part of parts) {
|
|
33
|
-
for (const [key, value] of Object.entries(part)) {
|
|
34
|
-
if (value === undefined) continue
|
|
35
|
-
if (key === "tool") {
|
|
36
|
-
const renaming = Object.keys(rename).length > 0
|
|
37
|
-
for (const [declared, def] of Object.entries(value as Record<string, unknown>)) {
|
|
38
|
-
// Withheld = no allowlist slot. Dropping it here is the point: one unlisted name
|
|
39
|
-
// makes the gateway reject the whole request, so the rest of the plugin still works.
|
|
40
|
-
if (withheld.has(declared)) continue
|
|
41
|
-
const name = rename[declared] ?? declared
|
|
42
|
-
if (tools[name]) console.warn(`[overclock] tool collision: ${name} (later module wins)`)
|
|
43
|
-
const d = def as { description?: unknown }
|
|
44
|
-
// Clone rather than mutate: the module owns its tool objects and may hold the
|
|
45
|
-
// same reference elsewhere.
|
|
46
|
-
tools[name] =
|
|
47
|
-
renaming && typeof d.description === "string"
|
|
48
|
-
? { ...d, description: renameInText(d.description, rename) }
|
|
49
|
-
: def
|
|
50
|
-
}
|
|
51
|
-
continue
|
|
52
|
-
}
|
|
53
|
-
const prev = merged[key] as ((...a: unknown[]) => Promise<void>) | undefined
|
|
54
|
-
const next = value as (...a: unknown[]) => Promise<void>
|
|
55
|
-
merged[key] = prev
|
|
56
|
-
? async (...args: unknown[]) => {
|
|
57
|
-
await prev(...args)
|
|
58
|
-
await next(...args)
|
|
59
|
-
}
|
|
60
|
-
: next
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (Object.keys(tools).length) merged.tool = tools
|
|
65
|
-
return merged as Hooks
|
|
66
|
-
}
|
|
1
|
+
export { mergeHooks } from "./core/lifecycle.ts"
|
|
2
|
+
export { applyToolPolicy, EMPTY_POLICY, renameInText, type ToolPolicy } from "./core/policy.ts"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime surface probe (doc: "probe for the surfaces we depend on ... instead of
|
|
3
|
+
* failing silently when upstream moves"). Dot-paths resolved against the SDK client;
|
|
4
|
+
* leaf must be a function by default.
|
|
5
|
+
*/
|
|
6
|
+
export function missingSurfaces(
|
|
7
|
+
client: unknown,
|
|
8
|
+
paths: string[],
|
|
9
|
+
requiredType: "function" | "object" | "defined" = "function",
|
|
10
|
+
): string[] {
|
|
11
|
+
return paths.filter((path) => {
|
|
12
|
+
let node: unknown = client
|
|
13
|
+
for (const key of path.split(".")) {
|
|
14
|
+
if (node == null || typeof node !== "object") return true
|
|
15
|
+
try {
|
|
16
|
+
node = (node as Record<string, unknown>)[key]
|
|
17
|
+
} catch {
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (requiredType === "function") return typeof node !== "function"
|
|
22
|
+
if (requiredType === "object") return node === null || typeof node !== "object"
|
|
23
|
+
return node === undefined
|
|
24
|
+
})
|
|
25
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSIX single-quote escape for safe shell argument interpolation.
|
|
3
|
+
*/
|
|
4
|
+
export function shellQuote(s: string): string {
|
|
5
|
+
return `'${s.replace(/'/g, `'\\''`)}'`
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const NON_INTERACTIVE_ENV: Record<string, string> = {
|
|
9
|
+
GIT_PAGER: "cat",
|
|
10
|
+
PAGER: "cat",
|
|
11
|
+
GIT_EDITOR: "true",
|
|
12
|
+
CI: "true",
|
|
13
|
+
DEBIAN_FRONTEND: "noninteractive",
|
|
14
|
+
TERM: "dumb",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pattern matching environment variable names that typically store credentials,
|
|
19
|
+
* secrets, authentication tokens, API keys, or private keys.
|
|
20
|
+
*/
|
|
21
|
+
export const SENSITIVE_ENV_PATTERN =
|
|
22
|
+
/(?:KEY|SECRET|TOKEN|AUTH|PASS(?:WORD|WD)?|CREDENTIAL|PRIVATE|SIGNING|DATABASE_URL|WEBHOOK|CERT|BEARER|COOKIE)/i
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Known system and transport variables that match sensitive keywords but are
|
|
26
|
+
* necessary for everyday development operations (SSH agent socket, TLS certificates).
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_PRESERVED_ENV: readonly string[] = [
|
|
29
|
+
"SSH_AUTH_SOCK",
|
|
30
|
+
"SSL_CERT_FILE",
|
|
31
|
+
"SSL_CERT_DIR",
|
|
32
|
+
"NODE_EXTRA_CA_CERTS",
|
|
33
|
+
"GIT_SSH_COMMAND",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Filter an environment map to remove sensitive keys (API keys, secrets, tokens).
|
|
38
|
+
* Safe system and build variables (PATH, HOME, USER, SHELL, TEMP, LANG, etc.)
|
|
39
|
+
* as well as essential operational credentials (SSH_AUTH_SOCK, certificates) are preserved.
|
|
40
|
+
*/
|
|
41
|
+
export function sanitizeEnv(
|
|
42
|
+
env: Record<string, string | undefined>,
|
|
43
|
+
allowlist?: string[],
|
|
44
|
+
): Record<string, string | undefined> {
|
|
45
|
+
const allowed = new Set([
|
|
46
|
+
...DEFAULT_PRESERVED_ENV.map((k) => k.toUpperCase()),
|
|
47
|
+
...(allowlist?.map((k) => k.toUpperCase()) ?? []),
|
|
48
|
+
])
|
|
49
|
+
const sanitized: Record<string, string | undefined> = {}
|
|
50
|
+
|
|
51
|
+
for (const [key, value] of Object.entries(env)) {
|
|
52
|
+
if (value === undefined) continue
|
|
53
|
+
if (allowed.has(key.toUpperCase())) {
|
|
54
|
+
sanitized[key] = value
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (SENSITIVE_ENV_PATTERN.test(key)) {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
sanitized[key] = value
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return sanitized
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Common regex patterns for tokens, API keys, private keys, and credential formats.
|
|
68
|
+
*/
|
|
69
|
+
export const SENSITIVE_OUTPUT_PATTERNS: { pattern: RegExp; replacement: string }[] = [
|
|
70
|
+
// Private keys
|
|
71
|
+
{
|
|
72
|
+
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
73
|
+
replacement: "[REDACTED_PRIVATE_KEY]",
|
|
74
|
+
},
|
|
75
|
+
// JWT tokens
|
|
76
|
+
{
|
|
77
|
+
pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\b/g,
|
|
78
|
+
replacement: "[REDACTED_JWT]",
|
|
79
|
+
},
|
|
80
|
+
// Bearer tokens
|
|
81
|
+
{
|
|
82
|
+
pattern: /\bBearer\s+[A-Za-z0-9_\-\.~+/]+=*/gi,
|
|
83
|
+
replacement: "Bearer [REDACTED_TOKEN]",
|
|
84
|
+
},
|
|
85
|
+
// OpenAI / Anthropic / AI vendor keys
|
|
86
|
+
{
|
|
87
|
+
pattern: /\b(?:sk|ant)-[a-zA-Z0-9_\-]{20,}\b/g,
|
|
88
|
+
replacement: "[REDACTED_API_KEY]",
|
|
89
|
+
},
|
|
90
|
+
// GitHub tokens (classic, fine-grained PATs, OAuth)
|
|
91
|
+
{
|
|
92
|
+
pattern: /\b(?:gh[pousr]_[A-Za-z0-9_]{36,255}|github_pat_[A-Za-z0-9_]{50,255})\b/g,
|
|
93
|
+
replacement: "[REDACTED_GITHUB_TOKEN]",
|
|
94
|
+
},
|
|
95
|
+
// AWS Access Key ID
|
|
96
|
+
{
|
|
97
|
+
pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
|
98
|
+
replacement: "[REDACTED_AWS_KEY]",
|
|
99
|
+
},
|
|
100
|
+
// Passwords in URLs (e.g. postgres://user:pass@host)
|
|
101
|
+
{
|
|
102
|
+
pattern: /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/:]+:)[^/@\s]+(@)/g,
|
|
103
|
+
replacement: "$1[REDACTED_PASSWORD]$2",
|
|
104
|
+
},
|
|
105
|
+
// Authorization headers
|
|
106
|
+
{
|
|
107
|
+
pattern: /(Authorization:\s*(?:Basic|Bearer|Token)\s+)[^\r\n]+/gi,
|
|
108
|
+
replacement: "$1[REDACTED_AUTH]",
|
|
109
|
+
},
|
|
110
|
+
// Key / secret / token assignments in key-value output (e.g. api_key="secret", token: secret)
|
|
111
|
+
{
|
|
112
|
+
pattern:
|
|
113
|
+
/((?:api[_-]?key|secret|token|password|passwd)\s*[:=]\s*["']?)[A-Za-z0-9_\-\.~+/]{8,}(["']?)/gi,
|
|
114
|
+
replacement: "$1[REDACTED]$2",
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Redacts sensitive tokens, API keys, credentials, and known environment secrets
|
|
120
|
+
* from output strings to prevent data leakage.
|
|
121
|
+
*/
|
|
122
|
+
export function redactSensitiveOutput(text: string, additionalSecrets?: string[]): string {
|
|
123
|
+
if (!text) return text
|
|
124
|
+
|
|
125
|
+
let redacted = text
|
|
126
|
+
|
|
127
|
+
// 1. Redact known secrets from process.env (values associated with sensitive keys, length >= 6)
|
|
128
|
+
const envSecrets: string[] = []
|
|
129
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
130
|
+
if (value && value.length >= 6 && SENSITIVE_ENV_PATTERN.test(key)) {
|
|
131
|
+
envSecrets.push(value)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const allSecrets = [...envSecrets, ...(additionalSecrets ?? [])]
|
|
136
|
+
// Sort by length descending to match longest secrets first
|
|
137
|
+
allSecrets.sort((a, b) => b.length - a.length)
|
|
138
|
+
|
|
139
|
+
for (const secret of allSecrets) {
|
|
140
|
+
if (secret && secret.length >= 6 && redacted.includes(secret)) {
|
|
141
|
+
redacted = redacted.replaceAll(secret, "[REDACTED_SECRET]")
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 2. Redact pattern matches
|
|
146
|
+
for (const { pattern, replacement } of SENSITIVE_OUTPUT_PATTERNS) {
|
|
147
|
+
redacted = redacted.replace(pattern, replacement)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return redacted
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Discover all descendant PIDs of a given process by inspecting the process tree.
|
|
155
|
+
*/
|
|
156
|
+
export async function getDescendantPids(rootPid: number): Promise<number[]> {
|
|
157
|
+
const pids: number[] = []
|
|
158
|
+
try {
|
|
159
|
+
const proc = Bun.spawn(["ps", "-A", "-o", "pid,ppid"], {
|
|
160
|
+
stdout: "pipe",
|
|
161
|
+
stderr: "ignore",
|
|
162
|
+
})
|
|
163
|
+
const text = await new Response(proc.stdout).text()
|
|
164
|
+
await proc.exited
|
|
165
|
+
|
|
166
|
+
const parentMap = new Map<number, number[]>()
|
|
167
|
+
for (const line of text.trim().split("\n").slice(1)) {
|
|
168
|
+
const parts = line.trim().split(/\s+/)
|
|
169
|
+
if (parts.length >= 2) {
|
|
170
|
+
const pid = Number(parts[0])
|
|
171
|
+
const ppid = Number(parts[1])
|
|
172
|
+
if (!isNaN(pid) && !isNaN(ppid)) {
|
|
173
|
+
if (!parentMap.has(ppid)) parentMap.set(ppid, [])
|
|
174
|
+
parentMap.get(ppid)!.push(pid)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const queue = [rootPid]
|
|
180
|
+
while (queue.length > 0) {
|
|
181
|
+
const curr = queue.shift()!
|
|
182
|
+
const children = parentMap.get(curr) ?? []
|
|
183
|
+
for (const child of children) {
|
|
184
|
+
pids.push(child)
|
|
185
|
+
queue.push(child)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch {}
|
|
189
|
+
return pids
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Kill a process and its child processes recursively on POSIX systems.
|
|
194
|
+
*/
|
|
195
|
+
export async function killProcessTree(
|
|
196
|
+
target: Bun.Subprocess | number,
|
|
197
|
+
signal: "SIGTERM" | "SIGKILL" = "SIGTERM",
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
const pid = typeof target === "number" ? target : target.pid
|
|
200
|
+
if (!pid) return
|
|
201
|
+
|
|
202
|
+
// 1. Gather all descendants in the process tree before sending signals
|
|
203
|
+
const descendants = await getDescendantPids(pid)
|
|
204
|
+
|
|
205
|
+
// 2. Try killing process group in case target is a process group leader
|
|
206
|
+
try {
|
|
207
|
+
process.kill(-pid, signal)
|
|
208
|
+
} catch {}
|
|
209
|
+
|
|
210
|
+
// 3. Kill all descendants (leaves first by iterating in reverse)
|
|
211
|
+
for (let i = descendants.length - 1; i >= 0; i--) {
|
|
212
|
+
try {
|
|
213
|
+
process.kill(descendants[i]!, signal)
|
|
214
|
+
} catch {}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 4. Fallback pkill -P for any newly spawned direct children
|
|
218
|
+
try {
|
|
219
|
+
const pkill = Bun.spawn(["pkill", `-${signal === "SIGKILL" ? "KILL" : "TERM"}`, "-P", String(pid)], {
|
|
220
|
+
stdout: "ignore",
|
|
221
|
+
stderr: "ignore",
|
|
222
|
+
})
|
|
223
|
+
await pkill.exited
|
|
224
|
+
} catch {}
|
|
225
|
+
|
|
226
|
+
// 5. Kill the root process
|
|
227
|
+
try {
|
|
228
|
+
if (typeof target === "number") {
|
|
229
|
+
process.kill(pid, signal)
|
|
230
|
+
} else {
|
|
231
|
+
target.kill(signal)
|
|
232
|
+
}
|
|
233
|
+
} catch {}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface ExecBashOptions {
|
|
237
|
+
cwd?: string
|
|
238
|
+
env?: Record<string, string | undefined>
|
|
239
|
+
timeoutMs?: number
|
|
240
|
+
onSpawn?: (proc: Bun.Subprocess) => void
|
|
241
|
+
/**
|
|
242
|
+
* If true (default), sensitive environment variables (API keys, secrets, tokens)
|
|
243
|
+
* are scrubbed from process.env before spawning child processes.
|
|
244
|
+
*/
|
|
245
|
+
sanitizeEnv?: boolean
|
|
246
|
+
/**
|
|
247
|
+
* Specific variable names to keep even if they match sensitive patterns.
|
|
248
|
+
*/
|
|
249
|
+
envAllowlist?: string[]
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface ExecBashResult {
|
|
253
|
+
code: number | null
|
|
254
|
+
stdout: string
|
|
255
|
+
stderr: string
|
|
256
|
+
combined: string
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Run a command via `bash -c`, capturing stdout, stderr, and exit code.
|
|
261
|
+
* Ensures non-interactive environment variables with correct precedence,
|
|
262
|
+
* environment sanitization against secret leakage, and process termination escalation.
|
|
263
|
+
*/
|
|
264
|
+
export async function execBash(command: string, options: ExecBashOptions = {}): Promise<ExecBashResult> {
|
|
265
|
+
const baseEnv =
|
|
266
|
+
options.sanitizeEnv === false ? process.env : sanitizeEnv(process.env, options.envAllowlist)
|
|
267
|
+
|
|
268
|
+
const mergedEnv: Record<string, string | undefined> = {
|
|
269
|
+
...baseEnv,
|
|
270
|
+
...NON_INTERACTIVE_ENV,
|
|
271
|
+
...options.env,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const proc = Bun.spawn(["bash", "-c", command], {
|
|
275
|
+
cwd: options.cwd,
|
|
276
|
+
env: mergedEnv,
|
|
277
|
+
stdout: "pipe",
|
|
278
|
+
stderr: "pipe",
|
|
279
|
+
})
|
|
280
|
+
options.onSpawn?.(proc)
|
|
281
|
+
|
|
282
|
+
let killEscalationTimer: ReturnType<typeof setTimeout> | undefined
|
|
283
|
+
const killTimer = options.timeoutMs
|
|
284
|
+
? setTimeout(() => {
|
|
285
|
+
void killProcessTree(proc, "SIGTERM")
|
|
286
|
+
killEscalationTimer = setTimeout(() => {
|
|
287
|
+
void killProcessTree(proc, "SIGKILL")
|
|
288
|
+
}, 2000)
|
|
289
|
+
killEscalationTimer.unref?.()
|
|
290
|
+
}, options.timeoutMs)
|
|
291
|
+
: undefined
|
|
292
|
+
|
|
293
|
+
// Protect against pipe leaks when background child processes keep stdout/stderr open
|
|
294
|
+
const streamTimeoutMs = options.timeoutMs ? options.timeoutMs + 2500 : undefined
|
|
295
|
+
const readStreamWithTimeout = (stream: ReadableStream, timeoutMs?: number): Promise<string> => {
|
|
296
|
+
const readPromise = new Response(stream).text().catch(() => "")
|
|
297
|
+
if (!timeoutMs) return readPromise
|
|
298
|
+
let timer: ReturnType<typeof setTimeout>
|
|
299
|
+
return Promise.race([
|
|
300
|
+
readPromise,
|
|
301
|
+
new Promise<string>((resolve) => {
|
|
302
|
+
timer = setTimeout(() => resolve(""), timeoutMs)
|
|
303
|
+
}),
|
|
304
|
+
]).finally(() => clearTimeout(timer))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
308
|
+
readStreamWithTimeout(proc.stdout, streamTimeoutMs),
|
|
309
|
+
readStreamWithTimeout(proc.stderr, streamTimeoutMs),
|
|
310
|
+
proc.exited,
|
|
311
|
+
])
|
|
312
|
+
|
|
313
|
+
if (killTimer) clearTimeout(killTimer)
|
|
314
|
+
if (killEscalationTimer) clearTimeout(killEscalationTimer)
|
|
315
|
+
|
|
316
|
+
return { code, stdout, stderr, combined: stdout + stderr }
|
|
317
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface TmuxPane {
|
|
2
|
+
paneId: string
|
|
3
|
+
close: () => Promise<void>
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Check if current process is running inside a tmux session. */
|
|
7
|
+
export function isInsideTmux(): boolean {
|
|
8
|
+
return Boolean(process.env.TMUX)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Spawns a background tmux split pane tailing the task log file.
|
|
13
|
+
* Uses `-d` to avoid stealing terminal focus.
|
|
14
|
+
* Uses positional parameters to prevent shell injection via title formatting.
|
|
15
|
+
*/
|
|
16
|
+
export async function spawnTaskPane(logPath: string, title?: string): Promise<TmuxPane | null> {
|
|
17
|
+
if (!isInsideTmux()) return null
|
|
18
|
+
try {
|
|
19
|
+
const args = title
|
|
20
|
+
? [
|
|
21
|
+
"tmux",
|
|
22
|
+
"split-window",
|
|
23
|
+
"-d",
|
|
24
|
+
"-P",
|
|
25
|
+
"-F",
|
|
26
|
+
"#{pane_id}",
|
|
27
|
+
"bash",
|
|
28
|
+
"-c",
|
|
29
|
+
'printf "=== [%s] ===\\n" "$1" && exec tail -f "$2"',
|
|
30
|
+
"_",
|
|
31
|
+
title,
|
|
32
|
+
logPath,
|
|
33
|
+
]
|
|
34
|
+
: ["tmux", "split-window", "-d", "-P", "-F", "#{pane_id}", "tail", "-f", logPath]
|
|
35
|
+
|
|
36
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" })
|
|
37
|
+
const [stdout, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
38
|
+
if (code !== 0) return null
|
|
39
|
+
|
|
40
|
+
const paneId = stdout.trim()
|
|
41
|
+
if (!paneId.startsWith("%")) return null
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
paneId,
|
|
45
|
+
close: async () => {
|
|
46
|
+
try {
|
|
47
|
+
const killProc = Bun.spawn(["tmux", "kill-pane", "-t", paneId], {
|
|
48
|
+
stdout: "ignore",
|
|
49
|
+
stderr: "ignore",
|
|
50
|
+
})
|
|
51
|
+
await killProc.exited
|
|
52
|
+
} catch {
|
|
53
|
+
// Pane might have been manually closed by user
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|