Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ The scrub also handles [OhMyOpenCode](https://github.com/anomalyco/ohmyopencode)

The scrub is **idempotent** — running it twice on the same string is a no-op.

## Modes

The default `aggressive` mode retains current scrubbing. Set
`MERIDIAN_OPENCODE_SCRUB_MODE=minimal` for surgical removal without inserting a
generic identity or replacing residual OpenCode/OhMyOpenCode words in preserved
prose. Both modes remove known identity wrappers, feedback/docs fingerprints,
powered-by lines and duplicate runtime environment blocks. Both retain the
client working-directory field, project instructions and tool/persona policies,
and normalize leftover blank lines. Minimal mode still removes the duplicate
environment preamble; preserving it would defeat the metering fix.

```sh
MERIDIAN_OPENCODE_SCRUB_MODE=minimal meridian
```

Other or unset values select `aggressive`. Adapter and passthrough marker guards
apply in both modes; genuine Claude Code prompts stay unchanged.

## Install

### Option 1: npm (recommended)
Expand Down Expand Up @@ -102,3 +120,10 @@ The built plugin is a single ES module at `dist/index.js` with `dist/index.d.ts`
## License

MIT

## Manual live verification

[Minimal/aggressive headless evidence and reproduction](https://github.com/rynfar/meridian-plugin-opencode-scrub/blob/main/docs/evidence/5-minimal-mode.md)
uses actual OpenCode, an independently installed tarball, Claude tool-result
receipts and same-session continuation. This manual credentialed gate is separate
from the ordinary unit/build CI and does not publish a package.
93 changes: 93 additions & 0 deletions docs/evidence/5-minimal-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# OpenCode scrub #5: minimal mode with runtime deduplication

## Accepted behavior and contributor credit

Source PR [#5](https://github.com/rynfar/meridian-plugin-opencode-scrub/pull/5)
head `40094cd9adaef32456befa54c1b969d611785395`, by briankeefe
`brian.c.keefe@gmail.com`, was cherry-picked as
`eddcf85a40701b94c6b95cfbe16b7cd644d57350`, preserving Author/AuthorDate.
A separate maintainer correction restores current main's package metadata,
passthrough guard, OMO 4.x fixes and newline handling before adding the mode.
The owner's July 10 review explicitly welcomes this opt-in feature and requires
runtime environment removal to remain enabled.

Unset/unknown modes keep aggressive behavior. `minimal` removes known identity
and runtime fingerprints, preserves client cwd and project/persona policy, and
avoids a generic replacement identity and residual brand-word rewriting. It
still removes duplicate environment preambles in both modes. No release,
package-version bump or core Meridian API change is part of this delivery.

## Before/after and meaningful controls

The actual authored minimal implementation retained the vanilla environment
preamble: `preambleRetained=true, cwdRetained=true, identityGeneric=false,
policyRetained=true`. This reproduces the owner's blocking finding with the
source function, rather than inferring it from a green unit suite.

Corrected tests cover vanilla environment removal/cwd/project preservation,
OMO fixture identity removal with tool/persona policy intact, idempotence,
request-time mode switching, unknown-value default, genuine Claude passthrough
identity and headerless OpenCode requests. Full suite: 28 passed, zero failed.
Standalone TypeScript and build pass; default outputs are byte-identical to
current main for both real prompt fixtures. An initial test expected an OMO
marker absent from the fixture; corrected the test to assert actual Operating
Mode/Instruction priority markers. No product defect claimed for that setup error.

## Actual headless client proof

Independently `npm pack`ed and installed the built plugin tarball in a disposable
npm project (version remains 0.2.3; not published). Both modes ran through actual
OpenCode **1.18.33**, Meridian **1.79.0** compiled from tested PR #1209 head
`d08011f8624d62ef8f66d261cc9b1295eaf7883d`, SDK **0.2.141**, Claude Code
**2.1.284**, actual **claude-opus-5-5**, Node on macOS arm64. Credentials are
read-only; request bodies/client output stay in private temporary directories.

| Mode | Actual read tools | Random client-only receipt in SDK request | Same client session continuation | Client errors | Scrub invocations/errors |
| --- | --- | --- | --- | --- | --- |
| minimal | 1 | yes | yes | 0 | 4 / 0 |
| aggressive | 1 | yes | yes | 0 | 4 / 0 |

In both runs the actual pre-transform main requests have powered-by/environment
preambles and cwd. Post-transform requests remove both fingerprints and retain
cwd. Minimal post-transform contexts have no generic identity; aggressive main
requests have the generic identity. This establishes the mode distinction in
actual client traffic. The title request has no tools or fingerprint and is
unchanged. The private receipt proves actual client execution and SDK delivery;
client exit zero alone would not establish that.

Escrowed harness: [scripts/e2e-minimal-opencode.mjs](../../scripts/e2e-minimal-opencode.mjs).
Reproduce after building Meridian and independently installing a packed plugin:

```sh
npm test
npx tsc --noEmit
npm run build
npm pack --pack-destination /private/tmp
# Install the resulting tarball in a disposable npm project; use its dist/index.js.
E2E_MERIDIAN_ROOT=/path/to/built/meridian \
E2E_PLUGIN_PATH=/path/to/disposable/node_modules/@rynfar/meridian-plugin-opencode-scrub/dist/index.js \
E2E_SCRUB_MODE=minimal node scripts/e2e-minimal-opencode.mjs
# Repeat with E2E_SCRUB_MODE=aggressive.
```

The harness requires OpenCode and existing Claude subscription credentials. It
uses isolated client/config/session directories and asserts actual tool results,
completed text, zero JSON error events, same-session continuation, plugin stats
and pre/post runtime fields. It preserves sanitized outcome summaries without
publishing raw prompts, credentials or session IDs.

## Adversarial findings and limits

The original feature's environmental preservation is corrected; current main's
adapter and passthrough guards survive reconciliation. No unrestricted brand
rewrite occurs in minimal preserved prose, while known identity wrappers are
still removed. Pure negative controls preserve genuine Claude context unchanged.
Actual client tools and continuation succeed in both modes. No public plugin
configuration/lifecycle change, server dependency or global credentials write.

The contributor's original failing client/model/flow was not specified. These
results do not claim to reproduce that original instability, guarantee billing
classification, or test a live OhMyOpenCode installation; OMO coverage uses the
repository's actual fixture. Billing classification can change upstream, so a
passing run alone is not a permanent metering guarantee. Durable results above
survive temporary local logs; full headless harness remains in the repository.
183 changes: 183 additions & 0 deletions scripts/e2e-minimal-opencode.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* Manual live gate for scrub modes with the actual headless OpenCode client.
* Requires OpenCode, Claude credentials for Meridian, and the OpenCode scrub plugin.
* Keeps all request bodies and client output in a private temporary directory.
*/
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { spawn, spawnSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { once } from 'node:events'

const meridianRoot = process.env.E2E_MERIDIAN_ROOT
assert(meridianRoot, 'Set E2E_MERIDIAN_ROOT to a built Meridian checkout')
const mode = process.env.E2E_SCRUB_MODE ?? 'minimal'
assert(['minimal', 'aggressive'].includes(mode))
const scrubPath = process.env.E2E_PLUGIN_PATH
assert(scrubPath, 'Set E2E_PLUGIN_PATH to the installed OpenCode scrub plugin entrypoint')
const model = process.env.E2E_MODEL ?? 'claude-opus-5-5'
const clientBin = process.env.E2E_OPENCODE_BIN ?? 'opencode'
const concurrency = Number(process.env.E2E_CONCURRENCY ?? 1)
const receipt = `CLIENT-READ-${randomUUID()}`
assert(Number.isInteger(concurrency) && concurrency >= 1 && concurrency <= 8)
const root = realpathSync(mkdtempSync(join(tmpdir(), 'meridian-opencode-admission-')))
console.log(JSON.stringify({ artifact: root }))
const meridianConfig = join(root, 'meridian-config')
for (const path of [meridianConfig, join(root, 'plugins')]) mkdirSync(path)
for (const key of Object.keys(process.env)) {
if (key.startsWith('MERIDIAN_') || key.startsWith('CLAUDE_PROXY_')) delete process.env[key]
}
Object.assign(process.env, {
MERIDIAN_CONFIG_DIR: meridianConfig,
MERIDIAN_SESSION_DIR: join(root, 'meridian-sessions'),
MERIDIAN_TELEMETRY_PERSIST: '0',
MERIDIAN_PASSTHROUGH: '1',
MERIDIAN_CREDENTIALS_READONLY: '1',
MERIDIAN_NO_UPDATE_CHECK: '1',
MERIDIAN_OPENCODE_SCRUB_MODE: mode,
})
const before = []
const after = []
globalThis.__opencodeAdmissionBefore = before
globalThis.__opencodeAdmissionAfter = after
const probe = (name, target) => `export default {
name: ${JSON.stringify(name)},
onRequest(ctx) {
const results = (ctx.messages || []).filter(m => m.role === 'user' && Array.isArray(m.content))
.flatMap(m => m.content.filter(b => b?.type === 'tool_result'));
const receiptClients = Array.from({length: ${concurrency}}, (_, index) => index).filter(index =>
results.some(b => JSON.stringify(b.content ?? '').includes(${JSON.stringify(receipt ?? '__unused_receipt__')} + '-' + index)));
globalThis.${target}.push({ adapter: ctx.adapter, toolCount: ctx.tools?.length ?? 0,
hasGenericIdentity: (ctx.systemContext || '').includes('You are an expert coding assistant.'),
hasPowered: (ctx.systemContext || '').includes('You are powered by the model named'),
hasEnvPreamble: (ctx.systemContext || '').includes('Here is some useful information about the environment you are running in:'),
hasWorkingDirectory: (ctx.systemContext || '').includes('Working directory:'),
hasClientReadResult: receiptClients.length > 0, receiptClients });
return ctx;
}
}`
const beforePath = join(root, 'before.js')
const afterPath = join(root, 'after.js')
writeFileSync(beforePath, probe('before-opencode-admission', '__opencodeAdmissionBefore'))
writeFileSync(afterPath, probe('after-opencode-admission', '__opencodeAdmissionAfter'))
const pluginConfigPath = join(root, 'plugins.json')
writeFileSync(pluginConfigPath, JSON.stringify({ plugins: [
{ path: beforePath, enabled: true },
{ path: scrubPath, enabled: true },
{ path: afterPath, enabled: true },
] }))
const clientVersion = spawnSync(clientBin, ['--version'], { encoding: 'utf8' })
assert.equal(clientVersion.status, 0, `OpenCode version command failed: ${clientVersion.error?.message ?? clientVersion.stderr}`)

async function runClient(index, url) {
const clientRoot = join(root, `client-${index}`)
const project = join(clientRoot, 'project')
const config = join(clientRoot, 'config')
for (const path of [clientRoot, project, config]) mkdirSync(path)
writeFileSync(join(project, 'receipt.txt'), receipt + '-' + index + '\n')
writeFileSync(join(config, 'opencode.json'), JSON.stringify({
$schema: 'https://opencode.ai/config.json',
plugin: [join(meridianRoot, 'dist', 'meridian')],
model: `anthropic/${model}`,
small_model: `anthropic/${model}`,
share: 'disabled',
permission: 'allow',
provider: { anthropic: { options: { apiKey: 'local-fixture', baseURL: url },
models: { [model]: { name: model, limit: { context: 200000, output: 1024 },
modalities: { input: ['text'], output: ['text'] }, temperature: false,
reasoning: false, tool_call: true } } } },
}))
const env = { ...process.env, OPENCODE_CONFIG_DIR: config, OPENCODE_DISABLE_AUTOUPDATE: '1' }
for (const kind of ['CONFIG', 'DATA', 'CACHE', 'STATE']) env[`XDG_${kind}_HOME`] = join(clientRoot, kind.toLowerCase())
for (const key of Object.keys(env)) {
if (/^(ANTHROPIC_|CLAUDE_|OPENAI_|MERIDIAN_|CLAUDE_PROXY_)/.test(key)) delete env[key]
}
async function invoke(args, name) {
const child = spawn(clientBin, args, { cwd: project, env, stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', chunk => { stdout += chunk })
child.stderr.on('data', chunk => { stderr += chunk })
const timeout = setTimeout(() => child.kill('SIGTERM'), 180000)
const exit = await new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', resolve)
}).finally(() => clearTimeout(timeout))
writeFileSync(join(clientRoot, `${name}.stdout`), stdout)
writeFileSync(join(clientRoot, `${name}.stderr`), stderr)
const events = stdout.split('\n').filter(line => line.startsWith('{')).flatMap(line => {
try { return [JSON.parse(line)] } catch { return [] }
})
return { exit, textEvents: events.filter(event => event.type === 'text' && event.part?.text).length,
toolEvents: events.filter(event => event.type === 'tool_use').length,
errorEvents: events.filter(event => event.type === 'error').length,
billingErrors: events.filter(event => event.type === 'error' && JSON.stringify(event).includes('billing_error')).length,
eventTypes: [...new Set(events.map(event => event.type))],
sessionId: events.find(event => typeof event.sessionID === 'string')?.sessionID }
}
const first = await invoke(['run', '--format', 'json', '--model', `anthropic/${model}`,
`Use the read tool to read ${join(project, 'receipt.txt')}, then give a brief acknowledgement.`], 'first')
const continued = first.exit === 0 && first.sessionId
? await invoke(['run', '--session', first.sessionId, '--format', 'json', '--model', `anthropic/${model}`,
'Reply with another short acknowledgement. Do not use tools.'], 'continued')
: undefined
const publicTurn = ({ sessionId: _sessionId, ...turn }) => ({ ...turn, sessionCaptured: Boolean(_sessionId) })
return { index, ...publicTurn(first), continuation: continued ? publicTurn(continued) : null,
continuationSameSession: continued ? continued.sessionId === first.sessionId : false,
artifact: clientRoot }
}

const { startProxyServer } = await import(pathToFileURL(join(meridianRoot, 'dist/server.js')).href)
let proxy
try {
proxy = await startProxyServer({ port: 0, host: '127.0.0.1', silent: true,
pluginConfigPath, pluginDir: join(root, 'plugins') })
if (!proxy.server.listening) await once(proxy.server, 'listening')
const url = `http://127.0.0.1:${proxy.server.address().port}`
const initial = await (await fetch(`${url}/plugins/list`)).json()
const scrub = initial.plugins.find(plugin => plugin.name === 'opencode-scrub')
assert.equal(scrub?.status, 'active', 'OpenCode scrub plugin did not load')
if (process.env.E2E_EXPECT_VERSION) assert.equal(scrub.version, process.env.E2E_EXPECT_VERSION)
const clients = await Promise.all(Array.from({ length: concurrency }, (_, index) => runClient(index, url)))
const final = await (await fetch(`${url}/plugins/list`)).json()
const scrubStats = final.plugins.find(plugin => plugin.name === 'opencode-scrub')?.stats?.hooks?.onRequest
const summary = { result: 'pending', mode, artifact: root, meridian: JSON.parse(readFileSync(join(meridianRoot, 'package.json'))).version,
opencode: clientVersion.stdout.trim(), model, plugin: scrub ? { version: scrub.version, onRequest: scrubStats } : null,
clients, before, after }
writeFileSync(join(root, 'summary.json'), JSON.stringify(summary, null, 2))
try {
assert(before.length >= concurrency && before.every(entry => entry.adapter === 'opencode'),
`The OpenCode client plugin did not identify requests; see ${root}/summary.json`)
assert(before.some(entry => entry.hasPowered && entry.hasEnvPreamble),
`The reported OpenCode system fingerprint was absent; see ${root}/summary.json`)
assert(after.length >= concurrency && after.every(entry => entry.adapter === 'opencode'),
`The scrub plugin changed request identity; see ${root}/summary.json`)
assert(clients.every(client => client.toolEvents > 0), 'Actual OpenCode did not execute a tool')
assert(clients.every(client => before.some(entry => entry.receiptClients.includes(client.index))),
'A random client-only read receipt never reached the SDK request')
assert(clients.every(client => client.exit === 0 && client.textEvents > 0 && client.errorEvents === 0
&& client.sessionCaptured && client.continuationSameSession && client.continuation?.exit === 0
&& client.continuation.textEvents > 0 && client.continuation.errorEvents === 0),
`OpenCode did not complete; see ${root}/summary.json and client logs`)
assert(scrubStats?.invocations >= concurrency * 2 && scrubStats.errors === 0,
`The OpenCode scrub plugin did not process every request; see ${root}/summary.json`)
if (mode === 'minimal') assert(after.every(entry => !entry.hasGenericIdentity), 'Minimal mode inserted a replacement identity')
assert(after.every(entry => !entry.hasPowered && !entry.hasEnvPreamble),
`The metering fingerprint remained after scrubbing; see ${root}/summary.json`)
assert(after.some(entry => entry.hasWorkingDirectory),
`The scrub plugin removed OpenCode's working-directory context; see ${root}/summary.json`)
} catch (error) {
console.log(JSON.stringify({ ...summary, result: 'FAIL' }))
throw error
}
summary.result = 'PASS'
writeFileSync(join(root, 'summary.json'), JSON.stringify(summary, null, 2))
console.log(JSON.stringify(summary))
} finally {
if (proxy) await proxy.close()
}
Loading
Loading