feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @file Safe archive extraction helpers for the history-import feature.
|
||||
*
|
||||
* Supports `.zip`, `.tar`, `.tar.gz`, `.tgz`, and plain `.gz` (single-file).
|
||||
* Every entry is validated against path traversal (no absolute paths, no
|
||||
* `..` segments) and resolved relative to the target directory. Non-regular
|
||||
* entries (symlinks, devices, hardlinks) are skipped rather than extracted.
|
||||
*
|
||||
* All functions are async and never throw on unknown formats — they return
|
||||
* `{ extracted: number, skipped: number }` so routes can surface counts.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const zlib = require("zlib");
|
||||
const { pipeline } = require("stream/promises");
|
||||
const crypto = require("crypto");
|
||||
|
||||
/**
|
||||
* Maximum total bytes any single archive is allowed to expand to during
|
||||
* extraction. Tunable via env so deployments with huge legitimate archives
|
||||
* can raise it; the default (4 GB) is generous for real-world transcript
|
||||
* bundles but low enough to stop most zip-bomb attacks from filling disk.
|
||||
*/
|
||||
const MAX_EXTRACT_BYTES = parseInt(
|
||||
process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES || String(4 * 1024 * 1024 * 1024),
|
||||
10
|
||||
);
|
||||
|
||||
class ExtractionLimitError extends Error {
|
||||
constructor(limit) {
|
||||
super(`Archive exceeded the ${limit}-byte extraction limit (possible zip bomb).`);
|
||||
this.code = "EXTRACTION_LIMIT_EXCEEDED";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `child` is contained within `parent` after normalization.
|
||||
* Used to reject archive entries that would escape the extraction root.
|
||||
*/
|
||||
function isPathInside(parent, child) {
|
||||
const p = path.resolve(parent) + path.sep;
|
||||
const c = path.resolve(child);
|
||||
return c === path.resolve(parent) || c.startsWith(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an archive entry name: strip leading slashes, collapse `..`,
|
||||
* reject if it escapes the root.
|
||||
*/
|
||||
function safeJoin(root, entryName) {
|
||||
const cleaned = String(entryName).replace(/^[/\\]+/, "");
|
||||
if (!cleaned || cleaned === "." || cleaned === "..") return null;
|
||||
const joined = path.join(root, cleaned);
|
||||
if (!isPathInside(root, joined)) return null;
|
||||
return joined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique temp directory for extraction under the OS tmpdir.
|
||||
* Caller is responsible for cleanup via `rmTempDir`.
|
||||
*/
|
||||
function mkTempDir(prefix = "ccam-import-") {
|
||||
const dir = path.join(os.tmpdir(), prefix + crypto.randomBytes(6).toString("hex"));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function rmTempDir(dir) {
|
||||
if (!dir) return;
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a `.zip` archive into `destDir` using adm-zip.
|
||||
* Lazily required so the dependency is optional at install time for users
|
||||
* who don't need archive upload.
|
||||
*/
|
||||
async function extractZip(zipPath, destDir) {
|
||||
let AdmZip;
|
||||
try {
|
||||
AdmZip = require("adm-zip");
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
"adm-zip is required to extract .zip archives. Run `npm install` to pick up new deps."
|
||||
);
|
||||
}
|
||||
const zip = new AdmZip(zipPath);
|
||||
const entries = zip.getEntries();
|
||||
|
||||
// Pre-check declared uncompressed sizes so we reject obvious zip bombs
|
||||
// before materializing any bytes to disk.
|
||||
let declared = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory) declared += entry.header?.size || 0;
|
||||
}
|
||||
if (declared > MAX_EXTRACT_BYTES) throw new ExtractionLimitError(MAX_EXTRACT_BYTES);
|
||||
|
||||
let extracted = 0;
|
||||
let skipped = 0;
|
||||
let writtenBytes = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
const target = safeJoin(destDir, entry.entryName);
|
||||
if (!target) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const data = entry.getData();
|
||||
writtenBytes += data.length;
|
||||
if (writtenBytes > MAX_EXTRACT_BYTES) throw new ExtractionLimitError(MAX_EXTRACT_BYTES);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
try {
|
||||
fs.writeFileSync(target, data);
|
||||
extracted++;
|
||||
} catch {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
return { extracted, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a `.tar`, `.tar.gz`, or `.tgz` archive into `destDir`.
|
||||
* Uses the `tar` package in streaming mode with `onentry` filter so we can
|
||||
* enforce path containment ourselves rather than relying on the lib's flags.
|
||||
*/
|
||||
async function extractTar(tarPath, destDir) {
|
||||
let tar;
|
||||
try {
|
||||
tar = require("tar");
|
||||
} catch {
|
||||
throw new Error(
|
||||
"tar is required to extract .tar/.tar.gz archives. Run `npm install` to pick up new deps."
|
||||
);
|
||||
}
|
||||
let extracted = 0;
|
||||
let skipped = 0;
|
||||
let writtenBytes = 0;
|
||||
|
||||
await tar.x({
|
||||
file: tarPath,
|
||||
cwd: destDir,
|
||||
strict: false,
|
||||
preservePaths: false,
|
||||
filter: (entryPath, entry) => {
|
||||
if (entry.type && entry.type !== "File" && entry.type !== "Directory") {
|
||||
skipped++;
|
||||
return false;
|
||||
}
|
||||
const target = safeJoin(destDir, entryPath);
|
||||
if (!target) {
|
||||
skipped++;
|
||||
return false;
|
||||
}
|
||||
if (entry.type === "File") {
|
||||
writtenBytes += entry.size || 0;
|
||||
if (writtenBytes > MAX_EXTRACT_BYTES) {
|
||||
// Surfacing the limit as a throw aborts tar.x; callers will see
|
||||
// ExtractionLimitError in the catch path.
|
||||
throw new ExtractionLimitError(MAX_EXTRACT_BYTES);
|
||||
}
|
||||
extracted++;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return { extracted, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a plain `.gz` file (not a tar archive) into `destDir`, reusing
|
||||
* the original filename with `.gz` stripped. Useful when a single JSONL was
|
||||
* gzipped for transfer.
|
||||
*/
|
||||
async function extractGzSingle(gzPath, destDir) {
|
||||
const base = path.basename(gzPath).replace(/\.gz$/i, "") || "decompressed.jsonl";
|
||||
const target = safeJoin(destDir, base);
|
||||
if (!target) return { extracted: 0, skipped: 1 };
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
|
||||
// Count decompressed bytes as they flow through gunzip; abort if we blow
|
||||
// past the extraction limit (defends against single-file gzip bombs).
|
||||
let written = 0;
|
||||
const { Transform } = require("stream");
|
||||
const limiter = new Transform({
|
||||
transform(chunk, _enc, cb) {
|
||||
written += chunk.length;
|
||||
if (written > MAX_EXTRACT_BYTES) {
|
||||
cb(new ExtractionLimitError(MAX_EXTRACT_BYTES));
|
||||
return;
|
||||
}
|
||||
cb(null, chunk);
|
||||
},
|
||||
});
|
||||
|
||||
await pipeline(
|
||||
fs.createReadStream(gzPath),
|
||||
zlib.createGunzip(),
|
||||
limiter,
|
||||
fs.createWriteStream(target)
|
||||
);
|
||||
return { extracted: 1, skipped: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the archive kind from the filename. Returns one of:
|
||||
* "zip" | "tar" | "tgz" | "gz" | "jsonl" | "meta" | "unknown"
|
||||
*/
|
||||
function detectKind(filename) {
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".zip")) return "zip";
|
||||
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "tgz";
|
||||
if (lower.endsWith(".tar")) return "tar";
|
||||
if (lower.endsWith(".meta.json")) return "meta";
|
||||
if (lower.endsWith(".jsonl")) return "jsonl";
|
||||
if (lower.endsWith(".gz")) return "gz";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch to the right extractor based on filename. For plain `.jsonl` and
|
||||
* `.meta.json` files we copy them through into `destDir`. Unknown files are
|
||||
* skipped so users can drop mixed content without failures.
|
||||
*/
|
||||
async function extractInto(srcPath, destDir, originalName) {
|
||||
const name = originalName || path.basename(srcPath);
|
||||
const kind = detectKind(name);
|
||||
switch (kind) {
|
||||
case "zip":
|
||||
return extractZip(srcPath, destDir);
|
||||
case "tar":
|
||||
case "tgz":
|
||||
return extractTar(srcPath, destDir);
|
||||
case "gz":
|
||||
return extractGzSingle(srcPath, destDir);
|
||||
case "jsonl":
|
||||
case "meta": {
|
||||
const target = safeJoin(destDir, name);
|
||||
if (!target) return { extracted: 0, skipped: 1 };
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.copyFileSync(srcPath, target);
|
||||
return { extracted: 1, skipped: 0 };
|
||||
}
|
||||
default:
|
||||
return { extracted: 0, skipped: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mkTempDir,
|
||||
rmTempDir,
|
||||
extractInto,
|
||||
extractZip,
|
||||
extractTar,
|
||||
extractGzSingle,
|
||||
detectKind,
|
||||
safeJoin,
|
||||
isPathInside,
|
||||
ExtractionLimitError,
|
||||
MAX_EXTRACT_BYTES,
|
||||
};
|
||||
Reference in New Issue
Block a user