@miphamai/cli 0.81.6 → 0.81.7
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 +9 -9
- package/bin/daemon.ts +7 -32
- package/bin/mipham.ts +43 -29
- package/package.json +5 -2
- package/skills/standard/mipham-code-setup.SKILL.md +3 -3
- package/src/agent/sub-agent.ts +12 -1
- package/src/commands/project.ts +92 -12
- package/src/config/keys-manager.ts +3 -3
- package/src/config/loader.ts +82 -1
- package/src/core/context.ts +10 -2
- package/src/core/engine.ts +19 -4
- package/src/core/metrics.ts +8 -0
- package/src/core/paths.ts +79 -0
- package/src/core/permission-rules.ts +121 -13
- package/src/core/permission.ts +3 -0
- package/src/core/session-log.ts +11 -2
- package/src/daemon/engine-capabilities.ts +131 -0
- package/src/daemon/index.ts +4 -1
- package/src/daemon/launch.ts +287 -0
- package/src/daemon/remote-engine.ts +2 -0
- package/src/daemon/server.ts +9 -0
- package/src/daemon/session-worker.ts +7 -4
- package/src/i18n-core/locales/en-US.json +6 -7
- package/src/i18n-core/locales/zh-CN.json +6 -7
- package/src/index.tsx +79 -0
- package/src/mcp/client.ts +4 -2
- package/src/providers/anthropic.ts +2 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +15 -0
- package/src/skills/bundled-skills.ts +1 -1
- package/src/telemetry/consent.ts +209 -0
- package/src/telemetry/crash.ts +197 -0
- package/src/telemetry/endpoint.ts +82 -0
- package/src/telemetry/index.ts +153 -0
- package/src/telemetry/payload.ts +141 -0
- package/src/telemetry/queue.ts +95 -0
- package/src/telemetry/redact.ts +127 -0
- package/src/telemetry/transport.ts +81 -0
- package/src/tools/agent/workflow.ts +11 -4
- package/src/tools/exec/bash.ts +6 -4
- package/src/tools/exec/enter-worktree.ts +6 -5
- package/src/tools/exec/exit-worktree.ts +10 -5
- package/src/tools/exec/git.ts +18 -8
- package/src/tools/system/config.ts +3 -3
- package/src/ui/app.tsx +40 -11
- package/src/ui/commands.ts +159 -34
- package/src/workflow/primitives/agent.ts +4 -2
- package/src/core/task-runner-tasks.json +0 -14
- package/src/core/task-runner.ts +0 -163
- package/src/skills/mipham/runtime.ts +0 -66
- package/src/skills/standard/runtime.ts +0 -62
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { getMetrics } from '../core/metrics'
|
|
3
|
+
import { resolveTelemetry, getOrCreateInstallId, type TelemetryConsent } from './consent'
|
|
4
|
+
import { enqueueSync } from './queue'
|
|
5
|
+
import { buildSessionEvent } from './payload'
|
|
6
|
+
import { buildCrashEvent, hasCrashed, installCrashHandlers } from './crash'
|
|
7
|
+
import { flushQueueInBackground } from './transport'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Telemetry facade.
|
|
11
|
+
*
|
|
12
|
+
* Data flow — collecting and sending are deliberately decoupled, because
|
|
13
|
+
* `process.on('exit')` cannot await async work:
|
|
14
|
+
*
|
|
15
|
+
* during a session counters accumulate in memory (`getMetrics()`); zero I/O
|
|
16
|
+
* at exit synchronous: whitelist snapshot + session metadata → queue
|
|
17
|
+
* next startup async, fire-and-forget: drain queue → POST → ack or keep
|
|
18
|
+
*
|
|
19
|
+
* Nothing here is a parallel counting system. `getMetrics()` already exists and
|
|
20
|
+
* is the single source for what actually got used; telemetry only *reads* it,
|
|
21
|
+
* and only for the whitelisted families (`payload.ts`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
interface TelemetryState {
|
|
25
|
+
consent: TelemetryConsent
|
|
26
|
+
installId: string
|
|
27
|
+
startedAt: number
|
|
28
|
+
/** Guards against a double flush when both SIGINT and a normal exit fire. */
|
|
29
|
+
flushed: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let state: TelemetryState | null = null
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The exit flush, kept by reference so `resetTelemetryState` can remove
|
|
36
|
+
* exactly this one — `removeAllListeners('exit')` would detach the runner's
|
|
37
|
+
* own teardown and every other exit path in the process.
|
|
38
|
+
*/
|
|
39
|
+
let onExit: (() => void) | null = null
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Start telemetry for this process.
|
|
43
|
+
*
|
|
44
|
+
* Safe to call once at startup. Returns the resolved consent so callers (and
|
|
45
|
+
* `/telemetry status`) can report why telemetry is on or off.
|
|
46
|
+
*/
|
|
47
|
+
export function initTelemetry(cwd: string = process.cwd()): TelemetryConsent {
|
|
48
|
+
const consent = resolveTelemetry(cwd)
|
|
49
|
+
const installId = consent.enabled ? getOrCreateInstallId(cwd) : ''
|
|
50
|
+
|
|
51
|
+
state = { consent, installId, startedAt: Date.now(), flushed: false }
|
|
52
|
+
|
|
53
|
+
// Crash capture is installed unconditionally, even when telemetry is off:
|
|
54
|
+
// it is what keeps a crash from becoming a silent hang. When telemetry is
|
|
55
|
+
// off the record is simply never uploaded.
|
|
56
|
+
installCrashHandlers()
|
|
57
|
+
|
|
58
|
+
// Likewise unconditional. The flush has to be registered while telemetry is
|
|
59
|
+
// still off, because the user can turn it on mid-session (`/telemetry on` →
|
|
60
|
+
// `enableTelemetryNow`) and that session must still be reported. Registering
|
|
61
|
+
// it here rather than in the TUI's own `process.on('exit')` also means the
|
|
62
|
+
// remote-attach and non-interactive paths are covered, not just the one that
|
|
63
|
+
// reaches the TUI setup.
|
|
64
|
+
if (!onExit) {
|
|
65
|
+
onExit = () => shutdownTelemetry()
|
|
66
|
+
process.on('exit', onExit)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (consent.enabled) flushQueueInBackground(consent.endpoint)
|
|
70
|
+
|
|
71
|
+
return consent
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Record a slash-command invocation.
|
|
76
|
+
*
|
|
77
|
+
* Unconditional, like every other counter in the registry — `getMetrics()` is
|
|
78
|
+
* a metrics registry used by the artifact server too, not a telemetry buffer.
|
|
79
|
+
* Whether the snapshot ever leaves the machine is decided at exit.
|
|
80
|
+
*/
|
|
81
|
+
export function recordCommand(name: string): void {
|
|
82
|
+
getMetrics().commandCalls.inc({ command_name: name })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Record a tool invocation that bypassed the engine's `executeTool` funnel. */
|
|
86
|
+
export function recordToolCall(name: string): void {
|
|
87
|
+
getMetrics().toolCalls.inc({ tool_name: name })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isTelemetryEnabled(): boolean {
|
|
91
|
+
return state?.consent.enabled === true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getTelemetryConsent(): TelemetryConsent | null {
|
|
95
|
+
return state?.consent ?? null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Form and queue the session payload. **Synchronous by requirement** — this is
|
|
100
|
+
* called from inside `process.on('exit')`, where an await would never settle.
|
|
101
|
+
*
|
|
102
|
+
* A no-op when telemetry is off, which is what makes "off" mean no collection,
|
|
103
|
+
* no queue file, and no network.
|
|
104
|
+
*/
|
|
105
|
+
export function shutdownTelemetry(now: Date = new Date()): void {
|
|
106
|
+
const current = state
|
|
107
|
+
if (!current || current.flushed) return
|
|
108
|
+
current.flushed = true
|
|
109
|
+
if (!current.consent.enabled) return
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
if (hasCrashed()) {
|
|
113
|
+
const crash = buildCrashEvent(current.installId)
|
|
114
|
+
if (crash) enqueueSync(crash)
|
|
115
|
+
}
|
|
116
|
+
enqueueSync(
|
|
117
|
+
buildSessionEvent({
|
|
118
|
+
installId: current.installId,
|
|
119
|
+
startedAt: current.startedAt,
|
|
120
|
+
endedAt: now.getTime(),
|
|
121
|
+
crashed: hasCrashed(),
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
} catch {
|
|
125
|
+
// A telemetry write must never break the exit path.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Test seam: drop facade state and unhook the exit flush. */
|
|
130
|
+
export function resetTelemetryState(): void {
|
|
131
|
+
state = null
|
|
132
|
+
if (onExit) process.off('exit', onExit)
|
|
133
|
+
onExit = null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Persist the user's answer and start reporting immediately, so `/telemetry on`
|
|
138
|
+
* takes effect in the running session rather than at the next launch.
|
|
139
|
+
*/
|
|
140
|
+
export function enableTelemetryNow(cwd: string = process.cwd()): void {
|
|
141
|
+
const consent = resolveTelemetry(cwd)
|
|
142
|
+
state = {
|
|
143
|
+
consent,
|
|
144
|
+
installId: getOrCreateInstallId(cwd),
|
|
145
|
+
startedAt: state?.startedAt ?? Date.now(),
|
|
146
|
+
flushed: false,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A fresh anonymous id, for `/telemetry reset-id`. */
|
|
151
|
+
export function newInstallId(): string {
|
|
152
|
+
return randomUUID()
|
|
153
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { getMetrics } from '../core/metrics'
|
|
3
|
+
import { PACKAGE_VERSION } from '../shared/package-info'
|
|
4
|
+
import type { QueuedEvent } from './queue'
|
|
5
|
+
import { runtimeTag } from './redact'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Payload construction.
|
|
9
|
+
*
|
|
10
|
+
* The counters are a **whitelist, not a whole-registry dump**. A dump would
|
|
11
|
+
* silently enrol every future counter into the uploaded payload, and a future
|
|
12
|
+
* counter's labels may well carry PII. A whitelist is explicit, can be written
|
|
13
|
+
* into the public data dictionary, and can be locked down by a test asserting
|
|
14
|
+
* the emitted key set is a subset of it.
|
|
15
|
+
*
|
|
16
|
+
* See `docs/telemetry.md` for the public data dictionary.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Bumped whenever the payload shape changes, so the endpoint can evolve.
|
|
21
|
+
*
|
|
22
|
+
* v2 (this version) = the crash event no longer carries `stackFrames`.
|
|
23
|
+
* The collector never kept them — "dimensional aggregates only" leaves a frame
|
|
24
|
+
* string nowhere to live — so sending them bought ~3 KB per crash of transfer
|
|
25
|
+
* and a privacy surface in exchange for nothing. The frame *count* stays: it is
|
|
26
|
+
* what keeps "this stack was short" distinguishable from "this stack was cut",
|
|
27
|
+
* and it is a single integer.
|
|
28
|
+
*
|
|
29
|
+
* The ordering was not optional: the collector had to accept v1 **before** any
|
|
30
|
+
* client stopped sending frames. Checked in `apps/telemetry/src/schema.ts`,
|
|
31
|
+
* which lists both versions.
|
|
32
|
+
*/
|
|
33
|
+
export const SCHEMA_VERSION = 2
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Counter family names cleared for upload, as they appear in the registry.
|
|
37
|
+
* Adding to this list is a privacy decision — it must be matched by a
|
|
38
|
+
* data-dictionary entry in the same commit.
|
|
39
|
+
*/
|
|
40
|
+
export const COUNTER_WHITELIST = [
|
|
41
|
+
'mipham_code_cli_invocations_total',
|
|
42
|
+
'mipham_code_command_calls_total',
|
|
43
|
+
'mipham_code_tool_calls_total',
|
|
44
|
+
'mipham_code_crsi_rule_applications_total',
|
|
45
|
+
'mipham_code_sis_interceptions_total',
|
|
46
|
+
] as const
|
|
47
|
+
|
|
48
|
+
/** Maximum length of a label value kept in the payload. */
|
|
49
|
+
const MAX_LABEL_LENGTH = 64
|
|
50
|
+
|
|
51
|
+
/** `Counter.toJSON()` reports `object`; this is the shape it actually returns. */
|
|
52
|
+
interface CounterJson {
|
|
53
|
+
name: string
|
|
54
|
+
series: { labels: string; value: number }[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pull the first `="…"` value out of the registry's formatted label string
|
|
59
|
+
* (e.g. `{tool_name="Bash"}` → `Bash`). Whitelisted families carry at most one
|
|
60
|
+
* label, so first-value is the whole story.
|
|
61
|
+
*/
|
|
62
|
+
function firstLabelValue(labels: string): string {
|
|
63
|
+
const match = /="((?:[^"\\]|\\.)*)"/.exec(labels)
|
|
64
|
+
if (!match || match[1] === undefined) return ''
|
|
65
|
+
return match[1]
|
|
66
|
+
.replace(/\\(.)/g, '$1')
|
|
67
|
+
.replace(/[^\x20-\x7e]/g, '')
|
|
68
|
+
.slice(0, MAX_LABEL_LENGTH)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Snapshot the whitelisted counters into a flat `Record<string, number>`.
|
|
73
|
+
*
|
|
74
|
+
* Keys are the family name with the `mipham_code_` prefix and `_total` suffix
|
|
75
|
+
* stripped, plus the label value when there is one:
|
|
76
|
+
* `mipham_code_cli_invocations_total` → `cli_invocations`
|
|
77
|
+
* `mipham_code_tool_calls_total{tool_name="Bash"}` → `tool_calls.Bash`
|
|
78
|
+
*
|
|
79
|
+
* Label cardinality: `tool_name` is closed by construction — there are only as
|
|
80
|
+
* many tool names as the registry declares. `command_name` is **not**: it comes
|
|
81
|
+
* from user input, so the caller is responsible for collapsing unrecognised
|
|
82
|
+
* names (see `commandLabelFor` in `ui/commands.ts`). `MAX_LABEL_LENGTH` below
|
|
83
|
+
* truncates a value, it does not bound how many keys there are.
|
|
84
|
+
*/
|
|
85
|
+
export function snapshotCounters(): Record<string, number> {
|
|
86
|
+
const metrics = getMetrics()
|
|
87
|
+
const { counters } = metrics.toJSON() as { counters: CounterJson[] }
|
|
88
|
+
const out: Record<string, number> = {}
|
|
89
|
+
|
|
90
|
+
for (const counter of counters) {
|
|
91
|
+
const family = counter.name
|
|
92
|
+
if (!(COUNTER_WHITELIST as readonly string[]).includes(family)) continue
|
|
93
|
+
|
|
94
|
+
const short = family.replace(/^mipham_code_/, '').replace(/_total$/, '')
|
|
95
|
+
|
|
96
|
+
for (const series of counter.series) {
|
|
97
|
+
const value = firstLabelValue(series.labels)
|
|
98
|
+
const key = value ? `${short}.${value}` : short
|
|
99
|
+
out[key] = (out[key] ?? 0) + series.value
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `darwin/arm64`, `linux/x64`, … — coarse platform identity, no hostname. */
|
|
107
|
+
function platformTag(): string {
|
|
108
|
+
return `${process.platform}/${process.arch}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface SessionMeta {
|
|
112
|
+
installId: string
|
|
113
|
+
startedAt: number
|
|
114
|
+
endedAt: number
|
|
115
|
+
crashed: boolean
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The `session` event: one per CLI launch, formed at exit.
|
|
120
|
+
*
|
|
121
|
+
* Contains counters and coarse environment facts only. It never carries
|
|
122
|
+
* conversation content, prompts, file contents, paths, user names, hostnames,
|
|
123
|
+
* API keys, or environment variable values.
|
|
124
|
+
*/
|
|
125
|
+
export function buildSessionEvent(meta: SessionMeta, now: Date = new Date()): QueuedEvent {
|
|
126
|
+
return {
|
|
127
|
+
id: randomUUID(),
|
|
128
|
+
kind: 'session',
|
|
129
|
+
payload: {
|
|
130
|
+
installId: meta.installId,
|
|
131
|
+
schemaVersion: SCHEMA_VERSION,
|
|
132
|
+
occurredAt: now.toISOString(),
|
|
133
|
+
appVersion: PACKAGE_VERSION,
|
|
134
|
+
runtime: runtimeTag(),
|
|
135
|
+
platform: platformTag(),
|
|
136
|
+
sessionDurationMs: Math.max(0, meta.endedAt - meta.startedAt),
|
|
137
|
+
crashed: meta.crashed,
|
|
138
|
+
counters: snapshotCounters(),
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
4
|
+
import { telemetryDir } from './consent'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Local queue of telemetry events awaiting upload.
|
|
8
|
+
*
|
|
9
|
+
* Every operation here is **synchronous**, and that is a requirement, not a
|
|
10
|
+
* convenience: the session payload is written from inside
|
|
11
|
+
* `process.on('exit')`, which cannot await async work. Every other exit-path
|
|
12
|
+
* write in the repo is synchronous for the same reason (`persistSession` in
|
|
13
|
+
* `index.tsx`, `EffectivenessTracker.persist()`).
|
|
14
|
+
*
|
|
15
|
+
* Sending is therefore decoupled from collecting: events are queued at exit and
|
|
16
|
+
* uploaded on the *next* startup, where async is allowed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Entries kept before the oldest are dropped. Bounds disk use on a machine that can never reach an endpoint. */
|
|
20
|
+
export const QUEUE_MAX_ENTRIES = 100
|
|
21
|
+
|
|
22
|
+
export interface QueuedEvent {
|
|
23
|
+
/** Stable per-event id, used to acknowledge a successful upload. */
|
|
24
|
+
id: string
|
|
25
|
+
kind: 'session' | 'crash'
|
|
26
|
+
payload: Record<string, unknown>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function queuePath(): string {
|
|
30
|
+
return join(telemetryDir(), 'queue.jsonl')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read the queue. A missing file is an empty queue; an unparsable *line* is
|
|
35
|
+
* skipped rather than thrown, so one truncated write can never wedge every
|
|
36
|
+
* future session.
|
|
37
|
+
*/
|
|
38
|
+
export function readQueue(): QueuedEvent[] {
|
|
39
|
+
const path = queuePath()
|
|
40
|
+
if (!existsSync(path)) return []
|
|
41
|
+
|
|
42
|
+
let raw: string
|
|
43
|
+
try {
|
|
44
|
+
raw = readFileSync(path, 'utf-8')
|
|
45
|
+
} catch {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const events: QueuedEvent[] = []
|
|
50
|
+
for (const line of raw.split('\n')) {
|
|
51
|
+
const trimmed = line.trim()
|
|
52
|
+
if (!trimmed) continue
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(trimmed) as QueuedEvent
|
|
55
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string') events.push(parsed)
|
|
56
|
+
} catch {
|
|
57
|
+
/* skip the damaged line, keep the rest */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return events
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Replace the queue wholesale, atomically.
|
|
65
|
+
*
|
|
66
|
+
* Capacity is enforced by dropping the *oldest* entries — the newest events
|
|
67
|
+
* describe the current version, which is the version worth hearing about.
|
|
68
|
+
* Silent: a queue write must never surface as a user-visible error, and must
|
|
69
|
+
* never take down an exit path.
|
|
70
|
+
*/
|
|
71
|
+
export function writeQueue(events: QueuedEvent[]): void {
|
|
72
|
+
try {
|
|
73
|
+
const kept = events.length > QUEUE_MAX_ENTRIES ? events.slice(-QUEUE_MAX_ENTRIES) : events
|
|
74
|
+
const body = kept.map((e) => JSON.stringify(e)).join('\n')
|
|
75
|
+
// atomicWriteFileSync does not create parents (same as the settings writer,
|
|
76
|
+
// which mkdirs first) — without this the queue silently never materialises
|
|
77
|
+
// on a fresh machine, where ~/.mipham/telemetry/ does not exist yet.
|
|
78
|
+
mkdirSync(telemetryDir(), { recursive: true })
|
|
79
|
+
atomicWriteFileSync(queuePath(), body.length > 0 ? body + '\n' : '', { mode: 0o600 })
|
|
80
|
+
} catch {
|
|
81
|
+
/* best-effort by design */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Append one event, dropping the oldest if the queue is at capacity. */
|
|
86
|
+
export function enqueueSync(event: QueuedEvent): void {
|
|
87
|
+
writeQueue([...readQueue(), event])
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Drop events whose ids are no longer wanted (i.e. they uploaded successfully). */
|
|
91
|
+
export function ackQueue(ids: Iterable<string>): void {
|
|
92
|
+
const done = new Set(ids)
|
|
93
|
+
if (done.size === 0) return
|
|
94
|
+
writeQueue(readQueue().filter((e) => !done.has(e.id)))
|
|
95
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Redaction for crash reports.
|
|
6
|
+
*
|
|
7
|
+
* Nothing in this module is reusable from the existing scrubbers: the four
|
|
8
|
+
* families in the repo (`credential-masker/`, `security/gate.ts`,
|
|
9
|
+
* `shared/sanitize.ts`, `skills/sanitizer.ts`) all match *credential shapes* —
|
|
10
|
+
* token prefixes, JWTs, `sk-ant-…`. None of them removes a user name out of a
|
|
11
|
+
* path. The only home→`~` helper is `ui/chat.tsx` `displayCwd()`, which is
|
|
12
|
+
* unexported, has zero call sites, and only ever looks at `process.cwd()` — it
|
|
13
|
+
* cannot touch a path inside an error object or a stack frame.
|
|
14
|
+
*
|
|
15
|
+
* So the guarantee below has to be carried by tests, not by reuse.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Frames kept by default. `Error.stackTraceLimit` is 10; we allow headroom. */
|
|
19
|
+
export const MAX_STACK_FRAMES = 15
|
|
20
|
+
|
|
21
|
+
/** Marker substituted for the working directory — hides the project name. */
|
|
22
|
+
const CWD_MARKER = '<cwd>'
|
|
23
|
+
|
|
24
|
+
/** Marker substituted for the home directory. */
|
|
25
|
+
const HOME_MARKER = '~'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Marker substituted for the first directory segment under home.
|
|
29
|
+
*
|
|
30
|
+
* Home replacement alone still discloses the user's own naming: a frame in
|
|
31
|
+
* `~/acme-secret-merger-2026/src/a.ts` would render as exactly that. The
|
|
32
|
+
* data dictionary promises no project names, so the segment right after `~` is
|
|
33
|
+
* collapsed too. Structure and the frame's own file name survive — which is
|
|
34
|
+
* what makes a frame useful — but the user's label for a directory does not.
|
|
35
|
+
*/
|
|
36
|
+
const DIR_MARKER = '<dir>'
|
|
37
|
+
|
|
38
|
+
function escapeRegExp(s: string): string {
|
|
39
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Replace absolute home/cwd paths with their markers, anywhere in the string.
|
|
44
|
+
*
|
|
45
|
+
* Order matters and is not arbitrary: cwd is normally *inside* home, so
|
|
46
|
+
* replacing home first would leave `~/proj/src/a.ts` — which leaks the project
|
|
47
|
+
* directory name. Replacing cwd first yields `<cwd>/src/a.ts`, which does not.
|
|
48
|
+
* This ordering is asserted by a test.
|
|
49
|
+
*
|
|
50
|
+
* Paths that are neither under cwd nor under home are left alone. Those are
|
|
51
|
+
* system/package locations (e.g. `/usr/local/lib/node_modules/…`) that contain
|
|
52
|
+
* no user data, and keeping them is what makes a frame useful for debugging.
|
|
53
|
+
*/
|
|
54
|
+
export function redactText(input: string, cwd?: string): string {
|
|
55
|
+
let out = input
|
|
56
|
+
|
|
57
|
+
if (cwd && cwd.length > 1) {
|
|
58
|
+
out = out.replace(new RegExp(escapeRegExp(cwd), 'g'), CWD_MARKER)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let home = ''
|
|
62
|
+
try {
|
|
63
|
+
home = homedir()
|
|
64
|
+
} catch {
|
|
65
|
+
/* homedir() can throw when neither HOME nor the passwd entry resolves */
|
|
66
|
+
}
|
|
67
|
+
if (home && home.length > 1) {
|
|
68
|
+
out = out.replace(new RegExp(escapeRegExp(home), 'g'), HOME_MARKER)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Collapse the first segment after `~` (both separators — Windows stacks use
|
|
72
|
+
// backslashes) so a user-chosen directory name is not disclosed. The matched
|
|
73
|
+
// separator is echoed back rather than normalised, so a Windows path does not
|
|
74
|
+
// come out with mixed separators.
|
|
75
|
+
out = out.replace(
|
|
76
|
+
/~([\\/])[^\\/\s:)]+/g,
|
|
77
|
+
(_match, sep: string) => `${HOME_MARKER}${sep}${DIR_MARKER}`,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface RedactedStack {
|
|
84
|
+
/** Redacted frame lines, truncated to `maxFrames`. */
|
|
85
|
+
frames: string[]
|
|
86
|
+
/** Frame count *before* truncation — truncation loses data, this bounds the loss. */
|
|
87
|
+
frameCount: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Extract and redact the frame lines of a V8 stack.
|
|
92
|
+
*
|
|
93
|
+
* The first line of a stack is `"TypeError: <message>"` — it is dropped
|
|
94
|
+
* outright, never redacted, because the message routinely embeds paths and user
|
|
95
|
+
* data. `crash.ts` sends only `hashMessage(error.message)` instead.
|
|
96
|
+
*/
|
|
97
|
+
export function redactStack(
|
|
98
|
+
stack: string,
|
|
99
|
+
opts: { cwd?: string; maxFrames?: number } = {},
|
|
100
|
+
): RedactedStack {
|
|
101
|
+
const { cwd = process.cwd(), maxFrames = MAX_STACK_FRAMES } = opts
|
|
102
|
+
|
|
103
|
+
const frameLines = stack.split('\n').filter((line) => /^\s*at\s/.test(line))
|
|
104
|
+
const redacted = frameLines.map((line) => redactText(line.trim(), cwd))
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
frames: redacted.slice(0, maxFrames),
|
|
108
|
+
frameCount: frameLines.length,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* sha256 of the error message, first 16 hex chars.
|
|
114
|
+
*
|
|
115
|
+
* Only the digest is reportable. The plaintext message is not — same reason the
|
|
116
|
+
* stack's first line is dropped.
|
|
117
|
+
*/
|
|
118
|
+
export function hashMessage(message: string): string {
|
|
119
|
+
return createHash('sha256').update(message).digest('hex').slice(0, 16)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Runtime tag for the session payload, e.g. `bun@1.2` / `node@22`. */
|
|
123
|
+
export function runtimeTag(): string {
|
|
124
|
+
const versions = process.versions as Record<string, string | undefined>
|
|
125
|
+
if (versions.bun) return `bun@${versions.bun}`
|
|
126
|
+
return `node@${(versions.node ?? '0').split('.')[0]}`
|
|
127
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { fetchWithRetry } from '../providers/fetch-utils'
|
|
2
|
+
import { ackQueue, readQueue, type QueuedEvent } from './queue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Upload queued events.
|
|
6
|
+
*
|
|
7
|
+
* Runs at *startup*, not at exit: `process.on('exit')` cannot await async work,
|
|
8
|
+
* so collecting and sending are decoupled — the session queue is written
|
|
9
|
+
* synchronously at exit and drained here on the next launch. Same shape as the
|
|
10
|
+
* existing startup update check (`shared/update.ts`), which is likewise
|
|
11
|
+
* fire-and-forget.
|
|
12
|
+
*
|
|
13
|
+
* Every failure is silent and leaves the event queued for the next attempt. A
|
|
14
|
+
* telemetry endpoint being unreachable must never be visible to the user.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Per-request budget. Short: this runs alongside startup, not during it. */
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 10_000
|
|
19
|
+
|
|
20
|
+
export interface FlushResult {
|
|
21
|
+
sent: number
|
|
22
|
+
failed: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Drain the queue to `endpoint`.
|
|
27
|
+
*
|
|
28
|
+
* Returns without sending anything when the endpoint is empty — which is the
|
|
29
|
+
* shipped default, so an unconfigured install performs **zero** network calls.
|
|
30
|
+
*/
|
|
31
|
+
export async function flushQueue(
|
|
32
|
+
endpoint: string,
|
|
33
|
+
opts: { fetchImpl?: typeof fetch; events?: QueuedEvent[] } = {},
|
|
34
|
+
): Promise<FlushResult> {
|
|
35
|
+
if (!endpoint) return { sent: 0, failed: 0 }
|
|
36
|
+
|
|
37
|
+
const events = opts.events ?? readQueue()
|
|
38
|
+
if (events.length === 0) return { sent: 0, failed: 0 }
|
|
39
|
+
|
|
40
|
+
const sent: string[] = []
|
|
41
|
+
let failed = 0
|
|
42
|
+
|
|
43
|
+
for (const event of events) {
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetchWithRetry(
|
|
46
|
+
endpoint,
|
|
47
|
+
{
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify(event),
|
|
51
|
+
},
|
|
52
|
+
{ timeout: REQUEST_TIMEOUT_MS, maxRetries: 2, baseDelay: 1000 },
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// 4xx means we are sending something the endpoint will never accept —
|
|
56
|
+
// retrying forever would wedge the queue behind a permanently bad event.
|
|
57
|
+
// Drop it on the floor rather than block every later event.
|
|
58
|
+
if (response.ok || (response.status >= 400 && response.status < 500)) {
|
|
59
|
+
sent.push(event.id)
|
|
60
|
+
} else {
|
|
61
|
+
failed++
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
failed++
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ackQueue(sent)
|
|
69
|
+
return { sent: sent.length, failed }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fire-and-forget startup flush. Never rejects, never blocks the caller, and
|
|
74
|
+
* never surfaces an error — the caller does not await it.
|
|
75
|
+
*/
|
|
76
|
+
export function flushQueueInBackground(endpoint: string): void {
|
|
77
|
+
if (!endpoint) return
|
|
78
|
+
void flushQueue(endpoint).catch(() => {
|
|
79
|
+
/* best-effort by design */
|
|
80
|
+
})
|
|
81
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
2
|
import { runWorkflow } from '../../workflow/runtime'
|
|
3
|
+
import { workflowScriptDir } from '../../core/paths.ts'
|
|
3
4
|
import type { QueryEngine } from '../../core/engine'
|
|
4
5
|
|
|
5
6
|
export const workflowTool: ToolDefinition = {
|
|
@@ -108,10 +109,11 @@ export const workflowTool: ToolDefinition = {
|
|
|
108
109
|
)
|
|
109
110
|
|
|
110
111
|
// Persist last-run state for /workflow save
|
|
112
|
+
let persistWarning = ''
|
|
111
113
|
try {
|
|
112
114
|
const { existsSync, mkdirSync, writeFileSync } = await import('node:fs')
|
|
113
115
|
const { join } = await import('node:path')
|
|
114
|
-
const workflowsDir =
|
|
116
|
+
const workflowsDir = workflowScriptDir(process.cwd())
|
|
115
117
|
if (!existsSync(workflowsDir)) {
|
|
116
118
|
mkdirSync(workflowsDir, { recursive: true })
|
|
117
119
|
}
|
|
@@ -120,8 +122,13 @@ export const workflowTool: ToolDefinition = {
|
|
|
120
122
|
JSON.stringify({ runId, script, timestamp: new Date().toISOString() }),
|
|
121
123
|
'utf-8',
|
|
122
124
|
)
|
|
123
|
-
} catch {
|
|
124
|
-
// best-effort —
|
|
125
|
+
} catch (err) {
|
|
126
|
+
// Persistence stays best-effort — the workflow itself succeeded, so a
|
|
127
|
+
// disk error must not fail it. But it must not be *silent* either:
|
|
128
|
+
// swallowing this made the next `/workflow save` report "No recent
|
|
129
|
+
// workflow run found" while the script sat intact in
|
|
130
|
+
// ~/.mipham/workflows/<runId>/script.js.
|
|
131
|
+
persistWarning = `\n\n⚠️ Could not persist last-run state for /workflow save: ${String(err)}`
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
let content = `Workflow ${runId} completed.\n\n`
|
|
@@ -130,7 +137,7 @@ export const workflowTool: ToolDefinition = {
|
|
|
130
137
|
}
|
|
131
138
|
content += `Result:\n${typeof result === 'string' ? result : JSON.stringify(result, null, 2)}`
|
|
132
139
|
|
|
133
|
-
return { success: true, content }
|
|
140
|
+
return { success: true, content: content + persistWarning }
|
|
134
141
|
} catch (err) {
|
|
135
142
|
return {
|
|
136
143
|
success: false,
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index
|
|
|
2
2
|
import { sanitizeCommand } from '../../shared/sanitize.ts'
|
|
3
3
|
import { DANGEROUS_GIT_PATTERNS } from './git.ts'
|
|
4
4
|
import { isUncOrDevicePath } from '../../security/path.ts'
|
|
5
|
+
import { findWorktreeMarker } from '../../core/paths.ts'
|
|
5
6
|
import type { Service } from '../../vajra'
|
|
6
7
|
import { toolKey } from '../seam'
|
|
7
8
|
import { withValidation } from '../validation'
|
|
@@ -334,9 +335,10 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
334
335
|
const timeout = Math.min((params.timeout as number) || 120_000, 600_000)
|
|
335
336
|
|
|
336
337
|
// P0-4: Worktree isolation — block cd escape attempts
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
338
|
+
// 标记取自 core/paths.ts:新目录与历史 .claude/worktrees/ 都认,
|
|
339
|
+
// 隔离度只增不减(只认新前缀会让旧工作树失去保护)。
|
|
340
|
+
const worktreeMarker = findWorktreeMarker(ctx.cwd)
|
|
341
|
+
if (worktreeMarker) {
|
|
340
342
|
// Detect cd to absolute paths outside the worktree
|
|
341
343
|
const cdEscapePattern = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/
|
|
342
344
|
const cdMatch = command.match(cdEscapePattern)
|
|
@@ -346,7 +348,7 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
346
348
|
const resolved = target.startsWith('/')
|
|
347
349
|
? target
|
|
348
350
|
: `${ctx.cwd}/${target}`.replace(/\/\.\//g, '/')
|
|
349
|
-
if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(
|
|
351
|
+
if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeMarker.root + '/')) {
|
|
350
352
|
return {
|
|
351
353
|
success: false,
|
|
352
354
|
content: '',
|