diff --git a/.gitignore b/.gitignore index 6ebddb7..53177b9 100644 --- a/.gitignore +++ b/.gitignore @@ -52,5 +52,6 @@ pnpm-lock.yaml # Superpowers local planning artifacts docs/superpowers/plans/ -# Local migration dry-run roots +# Local dev/admin script inputs +scripts/dev/run-migration-roots.local.txt scripts/dev/dry-run-roots.local.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9559f72..c7c28d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Local extraction rejection log for rejected compaction memory candidates: `~/.local/share/opencode-working-memory/extraction-rejections.jsonl`. - Sanitized real-workspace regression fixtures for memory cleanup migration behavior. +- Safe workspace residue cleanup tooling that dry-runs by default and quarantines definite temp/test workspace stores instead of deleting them. ### Changed @@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rewritten compaction memory prompt to reduce over-production of low-quality memories. - Changed quality cleanup migration to be conservative: it supersedes only high-confidence garbage patterns, including progress snapshots, raw errors, commit/CI snapshots, temporary status notes, active file snapshots, code/API signatures, path-heavy entries, and empty entries. - Soft heuristic failures (`bad_feedback`, `bad_decision`) are intentionally excluded from automatic migration cleanup to protect durable declarative memories such as branding rules, API facts, release rules, user workflow preferences, and architecture decisions. +- Isolated test runs under a temporary `XDG_DATA_HOME` so test workspaces no longer pollute real local workspace memory data. ### Recovery note diff --git a/README.md b/README.md index 2760930..5b877d6 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,15 @@ The goal is to remember durable facts, not every detail. Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y". +For local development cleanup, use: + +```bash +npm run cleanup:workspaces -- --dry-run +npm run cleanup:workspaces -- --quarantine +``` + +The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces. + ## Configuration OpenCode Working Memory works out of the box. @@ -212,6 +221,7 @@ cd opencode-working-memory npm install npm test npm run typecheck +npm run cleanup:workspaces -- --dry-run ``` ## Requirements diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d3ad70f..307b7f7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -16,6 +16,8 @@ The quality gate is now shared across compaction extraction and migration checks - **Audit logs**: automatic migration cleanup writes local JSONL audit records so superseded entries can be inspected and restored. - **Extraction rejection logs**: newly rejected compaction candidates are logged locally to help calibrate future quality rules. - **Regression coverage**: migration behavior is tested against sanitized real-workspace patterns to prevent mass false positives from coming back. +- **Workspace cleanup tooling**: a dev/admin cleanup command can dry-run or quarantine definite temp/test workspace residues without deleting unknown missing-root workspaces. +- **Test storage isolation**: test runs now use a temporary `XDG_DATA_HOME`, preventing fixture workspaces from polluting real local memory data. ### What Gets Cleaned Up @@ -63,6 +65,22 @@ Explicit and manual memories are also protected. If a useful memory is superseded, inspect the migration audit log and restore the entry by changing its status back to `"active"` in the workspace's `workspace-memory.json`. +### Workspace Residue Cleanup + +If old test/temp workspace stores already exist locally, inspect them first: + +```bash +npm run cleanup:workspaces -- --dry-run +``` + +To move definite temp/test residues into a local quarantine folder instead of deleting them: + +```bash +npm run cleanup:workspaces -- --quarantine +``` + +The cleanup command skips existing workspace roots and unknown missing-root workspaces by default. + ### Upgrade Notes - No configuration changes required. diff --git a/package.json b/package.json index 8f05d3b..7487aed 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "scripts": { "build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"", "typecheck": "tsc --noEmit", - "test": "node --test --experimental-strip-types tests/*.test.ts", + "test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts", + "cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts", "check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test" }, "keywords": [ diff --git a/scripts/dev/cleanup-workspaces.ts b/scripts/dev/cleanup-workspaces.ts new file mode 100644 index 0000000..8230364 --- /dev/null +++ b/scripts/dev/cleanup-workspaces.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * Safely inspect or quarantine stale test/temp workspace memory stores. + * + * Default mode is dry-run. Quarantine moves only definite temp/test residues. + * Unknown missing roots are reported but skipped unless --include-orphans is set. + */ + +import { cleanupWorkspaceResidues } from "../../src/workspace-cleanup.ts"; + +type CliOptions = { + mode: "dry-run" | "quarantine"; + dataHome?: string; + olderThanDays?: number; + includeOrphans: boolean; +}; + +function usage(): string { + return `Usage: + npm run cleanup:workspaces -- --dry-run + npm run cleanup:workspaces -- --quarantine + npm run cleanup:workspaces -- --quarantine --older-than-days 1 + +Options: + --dry-run List candidates without moving anything (default) + --quarantine Move definite temp/test residues to quarantine + --data-home Override XDG data home for testing/admin work + --older-than-days Only consider workspace dirs older than n days + --include-orphans Also quarantine missing non-temp roots (off by default) + --help Show this help +`; +} + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { mode: "dry-run", includeOrphans: false }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case "--dry-run": + options.mode = "dry-run"; + break; + case "--quarantine": + options.mode = "quarantine"; + break; + case "--data-home": + options.dataHome = argv[++i]; + if (!options.dataHome) throw new Error("--data-home requires a path"); + break; + case "--older-than-days": { + const value = Number(argv[++i]); + if (!Number.isFinite(value) || value < 0) throw new Error("--older-than-days requires a non-negative number"); + options.olderThanDays = value; + break; + } + case "--include-orphans": + options.includeOrphans = true; + break; + case "--help": + case "-h": + console.log(usage()); + process.exit(0); + default: + throw new Error(`Unknown option: ${arg}\n${usage()}`); + } + } + + return options; +} + +const options = parseArgs(process.argv.slice(2)); +const result = await cleanupWorkspaceResidues({ + dataHome: options.dataHome, + mode: options.mode, + includeOrphans: options.includeOrphans, + minAgeMs: options.olderThanDays === undefined ? undefined : options.olderThanDays * 24 * 60 * 60 * 1_000, +}); + +console.log(`Mode: ${result.mode}`); +console.log(`Scanned: ${result.results.length}`); +console.log(`Candidates: ${result.candidates.length}`); + +if (result.candidates.length > 0) { + console.log("\nCandidates:"); + for (const candidate of result.candidates) { + console.log(`- ${candidate.workspaceKey} ${candidate.classification} root=${candidate.root ?? ""}`); + console.log(` reasons=${candidate.reasons.join(",")}`); + } +} + +if (result.quarantined.length > 0) { + console.log(`\nQuarantined: ${result.quarantined.length}`); + console.log(`Quarantine dir: ${result.quarantineDir}`); +} + +const unknownOrphans = result.results.filter(item => item.classification === "orphan_unknown"); +if (unknownOrphans.length > 0 && !options.includeOrphans) { + console.log(`\nUnknown missing-root workspaces skipped: ${unknownOrphans.length}`); + console.log("Use --include-orphans only after manually confirming they are safe to quarantine."); +} diff --git a/scripts/dev/dry-run-migration.ts b/scripts/dev/run-migration.ts similarity index 71% rename from scripts/dev/dry-run-migration.ts rename to scripts/dev/run-migration.ts index 76a2e4f..4e86226 100644 --- a/scripts/dev/dry-run-migration.ts +++ b/scripts/dev/run-migration.ts @@ -2,12 +2,12 @@ * Local helper to trigger migration on workspace roots. * * Usage: - * MIGRATION_DRY_RUN_ROOTS=/path/a:/path/b bun run scripts/dev/dry-run-migration.ts + * MIGRATION_RUN_ROOTS=/path/a:/path/b bun run scripts/dev/run-migration.ts * * Or create a local file (gitignored): - * echo "/path/to/workspace1" > scripts/dev/dry-run-roots.local.txt - * echo "/path/to/workspace2" >> scripts/dev/dry-run-roots.local.txt - * bun run scripts/dev/dry-run-migration.ts + * echo "/path/to/workspace1" > scripts/dev/run-migration-roots.local.txt + * echo "/path/to/workspace2" >> scripts/dev/run-migration-roots.local.txt + * bun run scripts/dev/run-migration.ts */ import { existsSync } from "node:fs"; @@ -17,13 +17,13 @@ import { loadWorkspaceMemory } from "../../src/workspace-memory.ts"; async function getRoots(): Promise { // Priority 1: environment variable - const envRoots = process.env.MIGRATION_DRY_RUN_ROOTS; + const envRoots = process.env.MIGRATION_RUN_ROOTS; if (envRoots) { return envRoots.split(":").filter(root => root.length > 0); } // Priority 2: local file - const localFile = join(import.meta.dirname, "dry-run-roots.local.txt"); + const localFile = join(import.meta.dirname, "run-migration-roots.local.txt"); if (existsSync(localFile)) { const content = await readFile(localFile, "utf8"); return content.trim().split("\n").filter(root => root.length > 0); @@ -31,7 +31,7 @@ async function getRoots(): Promise { // No roots configured console.log("No workspace roots configured."); - console.log("Set MIGRATION_DRY_RUN_ROOTS=/path/a:/path/b or create dry-run-roots.local.txt"); + console.log("Set MIGRATION_RUN_ROOTS=/path/a:/path/b or create run-migration-roots.local.txt"); return []; } diff --git a/src/extractors.ts b/src/extractors.ts index 39feaa4..4370e81 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -5,6 +5,7 @@ import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from ". import { LONG_TERM_LIMITS } from "./types.ts"; import { assessMemoryQuality } from "./memory-quality.ts"; import { extractionRejectionLogPath } from "./paths.ts"; +import { redactCredentials } from "./redaction.ts"; function id(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; @@ -248,15 +249,6 @@ async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promi } } -function redactSensitiveText(text: string): string { - return text - .replace(/bearer\s+[a-zA-Z0-9._-]+/gi, "bearer [REDACTED]") - .replace(/token[=:]\s*[a-zA-Z0-9._-]+/gi, "token=[REDACTED]") - .replace(/password[=:]\s*[a-zA-Z0-9._-]+/gi, "password=[REDACTED]") - .replace(/secret[=:]\s*[a-zA-Z0-9._-]+/gi, "secret=[REDACTED]") - .replace(/api[-_]?key[=:]\s*[a-zA-Z0-9._-]+/gi, "api_key=[REDACTED]"); -} - function shouldAcceptWorkspaceMemoryCandidate( entry: { type: LongTermType; @@ -287,7 +279,7 @@ function shouldAcceptWorkspaceMemoryCandidate( void logExtractionRejection({ timestamp: new Date().toISOString(), type: entry.type, - text: redactSensitiveText(text), + text: redactCredentials(text), reasons: quality.reasons, source: "compaction", }); diff --git a/src/redaction.ts b/src/redaction.ts new file mode 100644 index 0000000..da9506e --- /dev/null +++ b/src/redaction.ts @@ -0,0 +1,73 @@ +/** + * Shared redaction utilities for sensitive credential patterns. + * Used by both workspace memory normalization and extraction rejection logging. + */ + +// Password labels in multiple languages +const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i; + +// Username labels in multiple languages +const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i; + +// Sensitive key labels +const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i; + +// Secret value pattern (excludes common delimiters and brackets) +const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`; + +// Prefix patterns for different credential types +const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; +const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; +const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; +const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`; +const BEARER_PREFIX = String.raw`(Bearer\s+)`; + +/** + * Redacts sensitive credentials from text. + * Handles: + * - PINs in multiple formats + * - Username/password pairs + * - Standalone passwords + * - Bearer tokens + * - API keys, secrets, credentials, auth tokens, private keys + * + * Supports multiple languages and delimiters (ASCII and CJK). + */ +export function redactCredentials(text: string): string { + let result = text; + + // 1. PIN + result = result.replace( + new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"), + "$1[REDACTED]", + ); + + // 2. Username+password pair + result = result.replace( + new RegExp( + String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, + "gi", + ), + "$1[REDACTED]$3$4[REDACTED]", + ); + + // 3. Standalone password + result = result.replace( + new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"), + "$1[REDACTED]", + ); + + // 4. Bearer tokens (but not "bearer token:" labels) + result = result.replace( + new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"), + "$1[REDACTED]", + ); + + // 5. Sensitive keys/tokens + result = result.replace( + new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"), + "$1[REDACTED]", + ); + + return result; +} \ No newline at end of file diff --git a/src/workspace-cleanup.ts b/src/workspace-cleanup.ts new file mode 100644 index 0000000..32f2a2a --- /dev/null +++ b/src/workspace-cleanup.ts @@ -0,0 +1,282 @@ +import { existsSync } from "node:fs"; +import { appendFile, mkdir, readFile, readdir, rename, stat } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { dataHome as defaultDataHome } from "./paths.ts"; + +export type WorkspaceCleanupClassification = + | "test_temp_definite" + | "orphan_unknown" + | "live_or_existing" + | "invalid_or_unreadable"; + +export type WorkspaceCleanupResult = { + workspaceKey: string; + workspaceDir: string; + root?: string; + rootExists: boolean; + classification: WorkspaceCleanupClassification; + reasons: string[]; + entryCount?: number; + migrations?: string[]; + updatedAt?: string; +}; + +export type WorkspaceCleanupScanOptions = { + dataHome?: string; + nowMs?: number; + minAgeMs?: number; + includeOrphans?: boolean; +}; + +export type WorkspaceCleanupScan = { + results: WorkspaceCleanupResult[]; + candidates: WorkspaceCleanupResult[]; +}; + +export type WorkspaceCleanupMode = "dry-run" | "quarantine"; + +export type WorkspaceCleanupOptions = WorkspaceCleanupScanOptions & { + mode?: WorkspaceCleanupMode; + now?: Date; +}; + +export type WorkspaceCleanupQuarantineEvent = WorkspaceCleanupResult & { + from: string; + to: string; + quarantinedAt: string; +}; + +export type WorkspaceCleanupRunResult = WorkspaceCleanupScan & { + mode: WorkspaceCleanupMode; + quarantined: WorkspaceCleanupQuarantineEvent[]; + quarantineDir?: string; +}; + +type WorkspaceMemoryShape = { + workspace?: { + root?: unknown; + key?: unknown; + }; + entries?: unknown[]; + migrations?: unknown[]; + updatedAt?: unknown; +}; + +const DEFAULT_MIN_AGE_MS = 10 * 60 * 1_000; + +const KNOWN_TEST_ROOT_PREFIXES = [ + "memory-plugin-test-", + "memory-plugin-prompt-", + "wm-", + "wm-quality-", + "wm-accounting-", + "wm-redact-", + "wm-normalized-", + "wm-ordering-", + "wm-extraction-", +]; + +function normalizePathForComparison(path: string): string { + return resolve(path).replace(/\/+$/, ""); +} + +function isInsidePath(path: string, parent: string): boolean { + const normalizedPath = normalizePathForComparison(path); + const normalizedParent = normalizePathForComparison(parent); + return normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}/`); +} + +export function isTempRoot(root: string, osTmpdir = tmpdir()): boolean { + const normalized = normalizePathForComparison(root); + const normalizedTmp = normalizePathForComparison(osTmpdir); + + if (isInsidePath(normalized, normalizedTmp)) return true; + if (isInsidePath(normalized, "/tmp")) return true; + if (isInsidePath(normalized, "/private/tmp")) return true; + + return /^\/(?:private\/)?var\/folders\/[^/]+\/[^/]+\/T(?:\/|$)/.test(normalized); +} + +export function isKnownTestWorkspaceRoot(root: string): boolean { + const name = basename(root); + return KNOWN_TEST_ROOT_PREFIXES.some(prefix => name.startsWith(prefix)); +} + +function classifyCandidate(result: WorkspaceCleanupResult, includeOrphans: boolean): boolean { + if (result.reasons.includes("recent_workspace_dir")) return false; + if (result.reasons.includes("lock_present")) return false; + if (result.classification === "test_temp_definite") return true; + return includeOrphans && result.classification === "orphan_unknown"; +} + +export async function classifyWorkspaceDir( + workspaceDir: string, + options: { nowMs?: number; minAgeMs?: number } = {}, +): Promise { + const workspaceKey = basename(workspaceDir); + const reasons: string[] = []; + const memoryPath = join(workspaceDir, "workspace-memory.json"); + + if (existsSync(`${memoryPath}.lock`)) { + reasons.push("lock_present"); + } + + let stats; + try { + stats = await stat(workspaceDir); + } catch { + return { + workspaceKey, + workspaceDir, + rootExists: false, + classification: "invalid_or_unreadable", + reasons: ["workspace_dir_unreadable"], + }; + } + + const minAgeMs = options.minAgeMs ?? DEFAULT_MIN_AGE_MS; + const nowMs = options.nowMs ?? Date.now(); + if (minAgeMs > 0 && nowMs - stats.mtimeMs < minAgeMs) { + reasons.push("recent_workspace_dir"); + } + + let store: WorkspaceMemoryShape; + try { + store = JSON.parse(await readFile(memoryPath, "utf8")) as WorkspaceMemoryShape; + } catch { + return { + workspaceKey, + workspaceDir, + rootExists: false, + classification: "invalid_or_unreadable", + reasons: [...reasons, "invalid_json"], + }; + } + + const root = typeof store.workspace?.root === "string" ? store.workspace.root : undefined; + const key = typeof store.workspace?.key === "string" ? store.workspace.key : workspaceKey; + const entryCount = Array.isArray(store.entries) ? store.entries.length : undefined; + const migrations = Array.isArray(store.migrations) ? store.migrations.filter((item): item is string => typeof item === "string") : undefined; + const updatedAt = typeof store.updatedAt === "string" ? store.updatedAt : undefined; + + if (!root) { + return { + workspaceKey: key, + workspaceDir, + rootExists: false, + classification: "invalid_or_unreadable", + reasons: [...reasons, "missing_workspace_root"], + entryCount, + migrations, + updatedAt, + }; + } + + const rootExists = existsSync(root); + if (rootExists) { + return { + workspaceKey: key, + workspaceDir, + root, + rootExists, + classification: "live_or_existing", + reasons: [...reasons, "root_exists"], + entryCount, + migrations, + updatedAt, + }; + } + + reasons.push("root_missing"); + const tempRoot = isTempRoot(root); + const testRoot = isKnownTestWorkspaceRoot(root); + if (tempRoot) reasons.push("root_under_temp"); + if (testRoot) reasons.push(`test_prefix_${KNOWN_TEST_ROOT_PREFIXES.find(prefix => basename(root).startsWith(prefix))?.replace(/-$/, "") ?? basename(root)}`); + + return { + workspaceKey: key, + workspaceDir, + root, + rootExists, + classification: tempRoot || testRoot ? "test_temp_definite" : "orphan_unknown", + reasons, + entryCount, + migrations, + updatedAt, + }; +} + +function workspacesDir(dataHome: string): string { + return join(dataHome, "opencode-working-memory", "workspaces"); +} + +export async function scanWorkspaceResidues(options: WorkspaceCleanupScanOptions = {}): Promise { + const root = workspacesDir(options.dataHome ?? defaultDataHome()); + const results: WorkspaceCleanupResult[] = []; + + let entries: string[]; + try { + entries = await readdir(root); + } catch { + return { results, candidates: [] }; + } + + for (const entry of entries) { + const workspaceDir = join(root, entry); + const stats = await stat(workspaceDir).catch(() => undefined); + if (!stats?.isDirectory()) continue; + + results.push(await classifyWorkspaceDir(workspaceDir, { + nowMs: options.nowMs, + minAgeMs: options.minAgeMs, + })); + } + + return { + results, + candidates: results.filter(result => classifyCandidate(result, options.includeOrphans ?? false)), + }; +} + +function quarantineName(now: Date): string { + return `workspace-cleanup-${now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`; +} + +export async function cleanupWorkspaceResidues(options: WorkspaceCleanupOptions = {}): Promise { + const mode = options.mode ?? "dry-run"; + const now = options.now ?? new Date(); + const scan = await scanWorkspaceResidues({ + dataHome: options.dataHome, + nowMs: options.nowMs, + minAgeMs: options.minAgeMs, + includeOrphans: options.includeOrphans, + }); + + if (mode === "dry-run" || scan.candidates.length === 0) { + return { ...scan, mode, quarantined: [] }; + } + + const dataHome = options.dataHome ?? defaultDataHome(); + const quarantineDir = join(dataHome, "opencode-working-memory", "quarantine", quarantineName(now)); + const quarantined: WorkspaceCleanupQuarantineEvent[] = []; + + for (const candidate of scan.candidates) { + const destination = join(quarantineDir, "workspaces", candidate.workspaceKey); + await mkdir(dirname(destination), { recursive: true }); + await rename(candidate.workspaceDir, destination); + + const event: WorkspaceCleanupQuarantineEvent = { + ...candidate, + from: candidate.workspaceDir, + to: destination, + quarantinedAt: now.toISOString(), + }; + quarantined.push(event); + + await mkdir(quarantineDir, { recursive: true }); + await appendFile(join(quarantineDir, "manifest.jsonl"), JSON.stringify(event) + "\n", "utf8"); + } + + return { ...scan, mode, quarantined, quarantineDir }; +} diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 78083bd..12d74bb 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -5,24 +5,13 @@ import { LONG_TERM_LIMITS } from "./types.ts"; import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts"; +import { redactCredentials } from "./redaction.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; const MIGRATION_ID = "2026-04-26-p0-cleanup"; const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup"; -const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`; - -const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i; -const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i; -const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i; - -const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; -const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; -const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; -const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`; -const BEARER_PREFIX = String.raw`(Bearer\s+)`; - export type MemoryConsolidationReason = | "promoted" | "absorbed_exact" @@ -250,44 +239,6 @@ export async function normalizeWorkspaceMemoryWithAccounting( }; } -export function redactCredentials(text: string): string { - let result = text; - - // 1. PIN - result = result.replace( - new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"), - "$1[REDACTED]", - ); - - // 2. Username+password pair - result = result.replace( - new RegExp( - String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, - "gi", - ), - "$1[REDACTED]$3$4[REDACTED]", - ); - - // 3. Standalone password - result = result.replace( - new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"), - "$1[REDACTED]", - ); - - // 4. Standalone sensitive keys/tokens - result = result.replace( - new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"), - "$1[REDACTED]", - ); - - result = result.replace( - new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"), - "$1[REDACTED]", - ); - - return result; -} - export function runMigrationP0Cleanup( store: WorkspaceMemoryStore, nowIso: string, diff --git a/tests/setup-xdg-data-home.ts b/tests/setup-xdg-data-home.ts new file mode 100644 index 0000000..3d45624 --- /dev/null +++ b/tests/setup-xdg-data-home.ts @@ -0,0 +1,21 @@ +import { after } from "node:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const previousXdgDataHome = process.env.XDG_DATA_HOME; +const previousTestFlag = process.env.OPENCODE_WORKING_MEMORY_TEST; +const testDataHome = await mkdtemp(join(tmpdir(), "opencode-working-memory-test-xdg-")); + +process.env.XDG_DATA_HOME = testDataHome; +process.env.OPENCODE_WORKING_MEMORY_TEST = "1"; + +after(async () => { + if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previousXdgDataHome; + + if (previousTestFlag === undefined) delete process.env.OPENCODE_WORKING_MEMORY_TEST; + else process.env.OPENCODE_WORKING_MEMORY_TEST = previousTestFlag; + + await rm(testDataHome, { recursive: true, force: true }); +}); diff --git a/tests/workspace-cleanup.test.ts b/tests/workspace-cleanup.test.ts new file mode 100644 index 0000000..cfe5ec0 --- /dev/null +++ b/tests/workspace-cleanup.test.ts @@ -0,0 +1,171 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + classifyWorkspaceDir, + cleanupWorkspaceResidues, + scanWorkspaceResidues, +} from "../src/workspace-cleanup.ts"; + +async function writeWorkspaceStore(dataHome: string, key: string, root: string): Promise { + const workspaceDir = join(dataHome, "opencode-working-memory", "workspaces", key); + await mkdir(workspaceDir, { recursive: true }); + await writeFile( + join(workspaceDir, "workspace-memory.json"), + JSON.stringify({ + version: 1, + workspace: { root, key }, + limits: { maxRenderedChars: 5200, maxEntries: 28 }, + entries: [], + updatedAt: "2026-04-28T00:00:00.000Z", + }, null, 2), + "utf8", + ); + return workspaceDir; +} + +test("workspace cleanup classifies missing temp test roots as definite residue", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + try { + const missingTempRoot = join(tmpdir(), "memory-plugin-test-missing-root"); + await rm(missingTempRoot, { recursive: true, force: true }); + const workspaceDir = await writeWorkspaceStore(dataHome, "definite", missingTempRoot); + + const result = await classifyWorkspaceDir(workspaceDir); + + assert.equal(result.classification, "test_temp_definite"); + assert.equal(result.rootExists, false); + assert.ok(result.reasons.includes("root_missing")); + assert.ok(result.reasons.some(reason => reason.startsWith("root_under_temp"))); + assert.ok(result.reasons.includes("test_prefix_memory-plugin-test")); + } finally { + await rm(dataHome, { recursive: true, force: true }); + } +}); + +test("workspace cleanup keeps existing temp roots live", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + const liveRoot = await mkdtemp(join(tmpdir(), "wm-quality-live-root-")); + try { + const workspaceDir = await writeWorkspaceStore(dataHome, "live", liveRoot); + + const result = await classifyWorkspaceDir(workspaceDir); + + assert.equal(result.classification, "live_or_existing"); + assert.equal(result.rootExists, true); + } finally { + await rm(dataHome, { recursive: true, force: true }); + await rm(liveRoot, { recursive: true, force: true }); + } +}); + +test("workspace cleanup reports missing non-temp roots as unknown orphans", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + try { + const missingNonTempRoot = `/definitely-not-temp-opencode-working-memory-test-${Date.now()}`; + const workspaceDir = await writeWorkspaceStore(dataHome, "orphan", missingNonTempRoot); + + const result = await classifyWorkspaceDir(workspaceDir); + + assert.equal(result.classification, "orphan_unknown"); + assert.equal(result.rootExists, false); + assert.ok(result.reasons.includes("root_missing")); + } finally { + await rm(dataHome, { recursive: true, force: true }); + } +}); + +test("workspace cleanup reports invalid stores without moving them", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + try { + const workspaceDir = join(dataHome, "opencode-working-memory", "workspaces", "invalid"); + await mkdir(workspaceDir, { recursive: true }); + await writeFile(join(workspaceDir, "workspace-memory.json"), "{ invalid", "utf8"); + + const result = await classifyWorkspaceDir(workspaceDir); + + assert.equal(result.classification, "invalid_or_unreadable"); + assert.ok(result.reasons.includes("invalid_json")); + + const cleanup = await cleanupWorkspaceResidues({ dataHome, mode: "quarantine" }); + assert.equal(cleanup.quarantined.length, 0); + assert.equal(existsSync(workspaceDir), true); + } finally { + await rm(dataHome, { recursive: true, force: true }); + } +}); + +test("workspace cleanup dry-run scans definite residue without moving it", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + try { + const missingTempRoot = join(tmpdir(), "wm-accounting-missing-root"); + await rm(missingTempRoot, { recursive: true, force: true }); + const workspaceDir = await writeWorkspaceStore(dataHome, "dryrun", missingTempRoot); + + const result = await cleanupWorkspaceResidues({ dataHome, minAgeMs: 0 }); + + assert.equal(result.mode, "dry-run"); + assert.equal(result.candidates.length, 1); + assert.equal(result.quarantined.length, 0); + assert.equal(existsSync(workspaceDir), true); + } finally { + await rm(dataHome, { recursive: true, force: true }); + } +}); + +test("workspace cleanup quarantine moves definite residue and writes manifest", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + try { + const missingTempRoot = join(tmpdir(), "wm-redact-missing-root"); + await rm(missingTempRoot, { recursive: true, force: true }); + const definiteDir = await writeWorkspaceStore(dataHome, "definite", missingTempRoot); + const orphanDir = await writeWorkspaceStore(dataHome, "orphan", `/definitely-not-temp-opencode-working-memory-test-${Date.now()}`); + + const result = await cleanupWorkspaceResidues({ + dataHome, + mode: "quarantine", + minAgeMs: 0, + now: new Date("2026-04-28T12:00:00.000Z"), + }); + + assert.equal(result.quarantined.length, 1); + assert.equal(result.quarantined[0]?.workspaceKey, "definite"); + assert.equal(existsSync(definiteDir), false); + assert.equal(existsSync(orphanDir), true); + assert.ok(result.quarantineDir); + assert.equal(existsSync(join(result.quarantineDir!, "workspaces", "definite", "workspace-memory.json")), true); + + const manifest = await readFile(join(result.quarantineDir!, "manifest.jsonl"), "utf8"); + const event = JSON.parse(manifest.trim()); + assert.equal(event.workspaceKey, "definite"); + assert.equal(event.classification, "test_temp_definite"); + assert.equal(event.root, missingTempRoot); + } finally { + await rm(dataHome, { recursive: true, force: true }); + } +}); + +test("workspace cleanup skips recently updated definite residue", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + try { + const missingTempRoot = join(tmpdir(), "wm-extraction-missing-root"); + await rm(missingTempRoot, { recursive: true, force: true }); + const workspaceDir = await writeWorkspaceStore(dataHome, "recent", missingTempRoot); + + const stats = await stat(workspaceDir); + const result = await scanWorkspaceResidues({ + dataHome, + nowMs: stats.mtimeMs + 1_000, + minAgeMs: 10 * 60 * 1_000, + }); + + assert.equal(result.candidates.length, 0); + assert.equal(result.results.find(item => item.workspaceKey === "recent")?.classification, "test_temp_definite"); + assert.ok(result.results.find(item => item.workspaceKey === "recent")?.reasons.includes("recent_workspace_dir")); + } finally { + await rm(dataHome, { recursive: true, force: true }); + } +}); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index ed42ca5..bd48ee9 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -14,13 +14,13 @@ import { normalizeWorkspaceMemoryWithAccounting, workspaceMemoryExactKey, workspaceMemoryIdentityKey, - redactCredentials, runMigrationP0Cleanup, runMigrationQualityCleanup, loadWorkspaceMemory, saveWorkspaceMemory, updateWorkspaceMemoryWithAccounting, } from "../src/workspace-memory.ts"; +import { redactCredentials } from "../src/redaction.ts"; import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts"; import { reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts"; import { REAL_WORKSPACE_FIXTURES } from "./fixtures/real-workspaces-snapshot.ts";