sdlc-agent-4-enterprise-server 1.55.3 → 1.55.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/dist/config/ContentProxy.d.ts +5 -1
- package/dist/config/ContentProxy.js +21 -1
- package/dist/config/ContentProxy.js.map +1 -1
- package/dist/modules/orchestration/artifact-cleanup/CleanupSweep.d.ts +60 -0
- package/dist/modules/orchestration/artifact-cleanup/CleanupSweep.js +159 -0
- package/dist/modules/orchestration/artifact-cleanup/CleanupSweep.js.map +1 -0
- package/dist/modules/orchestration/artifact-cleanup/artifact-roots.d.ts +14 -0
- package/dist/modules/orchestration/artifact-cleanup/artifact-roots.js +38 -0
- package/dist/modules/orchestration/artifact-cleanup/artifact-roots.js.map +1 -0
- package/dist/modules/orchestration/artifact-cleanup/cleanup-config.d.ts +23 -0
- package/dist/modules/orchestration/artifact-cleanup/cleanup-config.js +35 -0
- package/dist/modules/orchestration/artifact-cleanup/cleanup-config.js.map +1 -0
- package/dist/modules/orchestration/artifact-cleanup/delete-artifact.d.ts +21 -0
- package/dist/modules/orchestration/artifact-cleanup/delete-artifact.js +42 -0
- package/dist/modules/orchestration/artifact-cleanup/delete-artifact.js.map +1 -0
- package/package.json +1 -1
- package/src/config/ContentProxy.ts +21 -1
- package/src/modules/orchestration/artifact-cleanup/CleanupSweep.ts +185 -0
- package/src/modules/orchestration/artifact-cleanup/__tests__/CleanupSweep.test.ts +199 -0
- package/src/modules/orchestration/artifact-cleanup/artifact-roots.ts +46 -0
- package/src/modules/orchestration/artifact-cleanup/cleanup-config.ts +48 -0
- package/src/modules/orchestration/artifact-cleanup/delete-artifact.ts +46 -0
- package/src/modules/orchestration/content-proxy/__tests__/content-proxy.cleanup.test.ts +166 -0
|
@@ -30,5 +30,9 @@ export interface ContentProxyOverride {
|
|
|
30
30
|
/** Disable the adapter entirely for this tool. */
|
|
31
31
|
enabled?: boolean;
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
33
|
+
/** Artifact output roots — dedicated sinks for orphaned artifact files. */
|
|
34
|
+
export declare function artifactOutputRoots(): readonly string[];
|
|
35
|
+
/** Check whether a real path falls inside one of the artifact roots. */
|
|
36
|
+
export declare function isPathWithinRoot(root: string, candidate: string): boolean;
|
|
37
|
+
/** Per-tool escape hatch for tools that defy the naming/description convention. */
|
|
34
38
|
export declare const CONTENT_PROXY_OVERRIDES: Record<string, ContentProxyOverride>;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Overrides exist only for tools that do not follow the convention.
|
|
13
13
|
*/
|
|
14
|
+
import * as fs from 'fs';
|
|
14
15
|
/** Tool-name prefixes whose result is a file written by the child server. */
|
|
15
16
|
export const FILE_TOOL_NAME_PREFIXES = ['export_', 'render_', 'generate_'];
|
|
16
17
|
/** Description fallback: "Convert ... to PDF/DOCX/…" (target must be a file format). */
|
|
@@ -31,6 +32,25 @@ export const CONTENT_PARAM_ALIASES = [
|
|
|
31
32
|
export const NON_CONTENT_PARAM = /^(content_base64|.*_base64|file_path|.*_path|path|url|uri|dir|.*_id|id|name|.*_name|key|.*_key|token|secret|password|mime|.*_type|type|format|mode|encoding)$/i;
|
|
32
33
|
/** Maximum artifact size inlined as base64 (mirrors the extension pre-read cap). */
|
|
33
34
|
export const MAX_ARTIFACT_BYTES = 25 * 1024 * 1024;
|
|
34
|
-
/**
|
|
35
|
+
/** Artifact output roots — dedicated sinks for orphaned artifact files. */
|
|
36
|
+
export function artifactOutputRoots() {
|
|
37
|
+
// Convention: ARTIFACT_OUTPUT_ROOTS env var, or default per-workspace roots
|
|
38
|
+
const env = process.env.ARTIFACT_OUTPUT_ROOTS;
|
|
39
|
+
if (env)
|
|
40
|
+
return env.split(':').map((s) => s.trim()).filter(Boolean);
|
|
41
|
+
// Default: no roots configured → sweep will find nothing (safe no-op)
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
/** Check whether a real path falls inside one of the artifact roots. */
|
|
45
|
+
export function isPathWithinRoot(root, candidate) {
|
|
46
|
+
try {
|
|
47
|
+
const real = fs.realpathSync(candidate);
|
|
48
|
+
return artifactOutputRoots().some((r) => real.startsWith(r));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Per-tool escape hatch for tools that defy the naming/description convention. */
|
|
35
55
|
export const CONTENT_PROXY_OVERRIDES = {};
|
|
36
56
|
//# sourceMappingURL=ContentProxy.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ContentProxy.js","sourceRoot":"","sources":["../../src/config/ContentProxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,6EAA6E;AAC7E,MAAM,CAAC,MAAM,uBAAuB,GAAsB,CAAC,SAAS,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC;AAE9F,wFAAwF;AACxF,MAAM,CAAC,MAAM,uBAAuB,GAAG,gEAAgE,CAAC;AAExG,qEAAqE;AACrE,MAAM,CAAC,MAAM,qBAAqB,GAAsB;IACtD,UAAU;IACV,kBAAkB;IAClB,SAAS;IACT,MAAM;IACN,KAAK;IACL,MAAM;IACN,MAAM;IACN,QAAQ;IACR,UAAU;CACX,CAAC;AAEF,6EAA6E;AAC7E,MAAM,CAAC,MAAM,iBAAiB,GAC5B,gKAAgK,CAAC;AAEnK,oFAAoF;AACpF,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC;AAYnD,
|
|
1
|
+
{"version":3,"file":"ContentProxy.js","sourceRoot":"","sources":["../../src/config/ContentProxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,6EAA6E;AAC7E,MAAM,CAAC,MAAM,uBAAuB,GAAsB,CAAC,SAAS,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC;AAE9F,wFAAwF;AACxF,MAAM,CAAC,MAAM,uBAAuB,GAAG,gEAAgE,CAAC;AAExG,qEAAqE;AACrE,MAAM,CAAC,MAAM,qBAAqB,GAAsB;IACtD,UAAU;IACV,kBAAkB;IAClB,SAAS;IACT,MAAM;IACN,KAAK;IACL,MAAM;IACN,MAAM;IACN,QAAQ;IACR,UAAU;CACX,CAAC;AAEF,6EAA6E;AAC7E,MAAM,CAAC,MAAM,iBAAiB,GAC5B,gKAAgK,CAAC;AAEnK,oFAAoF;AACpF,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC;AAYnD,2EAA2E;AAC3E,MAAM,UAAU,mBAAmB;IACjC,4EAA4E;IAC5E,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC;IAC9C,IAAI,GAAG;QAAE,OAAO,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACpE,sEAAsE;IACtE,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,SAAiB;IAC9D,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,EAAE,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC;QACxC,OAAO,mBAAmB,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;IAC/D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,mFAAmF;AACnF,MAAM,CAAC,MAAM,uBAAuB,GAAyC,EAAE,CAAC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CleanupSweep — OI-15 periodic orphan reaper (TDD §9.2, BRD STORY 3 RD-8).
|
|
3
|
+
*
|
|
4
|
+
* Safety net for artifacts that escaped immediate cleanup (OI-14): crashes,
|
|
5
|
+
* power loss, permission errors. Per tick it scans ONLY the dedicated
|
|
6
|
+
* ARTIFACT_OUTPUT_ROOTS (SECURITY-REVIEW approval condition 2 — never the raw
|
|
7
|
+
* OS temp root, never the agent workspace). Roots are dedicated artifact
|
|
8
|
+
* sinks, so EVERY regular non-symlink file older than the TTL is swept —
|
|
9
|
+
* including job-dir nesting (`<root>/<job>/file`, how markdown-exporter
|
|
10
|
+
* writes) and arbitrary names (`STP-v1-SA4E-380.docx`); filename prefixes
|
|
11
|
+
* are NOT a criterion. Directories, symlinks, young files and anything
|
|
12
|
+
* outside the roots are always kept. Job directories emptied by the sweep
|
|
13
|
+
* are removed too. Each run is hard-bounded by maxRunMs and logs one
|
|
14
|
+
* summary line `{ removed, failed, skipped, durationMs }` with basenames only.
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle: started/stopped next to the health monitor (OrchestrationModule);
|
|
17
|
+
* `enabled: false` never starts a timer.
|
|
18
|
+
*/
|
|
19
|
+
import type { Logger } from 'pino';
|
|
20
|
+
import { type ArtifactCleanupConfig } from './cleanup-config.js';
|
|
21
|
+
export interface SweepResult {
|
|
22
|
+
removed: number;
|
|
23
|
+
failed: number;
|
|
24
|
+
skipped: number;
|
|
25
|
+
durationMs: number;
|
|
26
|
+
/** True when the tick stopped early because maxRunMs elapsed (UT-48). */
|
|
27
|
+
bounded: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface CleanupSweepDeps {
|
|
30
|
+
/** Overrides loaded on top of the env-derived config (tests, ops). */
|
|
31
|
+
config?: Partial<ArtifactCleanupConfig>;
|
|
32
|
+
/** Scan roots override (tests); defaults to the configured artifact roots. */
|
|
33
|
+
roots?: readonly string[];
|
|
34
|
+
/** Deletion hook override (tests); defaults to fs.unlinkSync. */
|
|
35
|
+
removeFile?: (filePath: string) => void;
|
|
36
|
+
}
|
|
37
|
+
export declare class CleanupSweep {
|
|
38
|
+
private readonly logger;
|
|
39
|
+
private readonly config;
|
|
40
|
+
private readonly roots;
|
|
41
|
+
private readonly removeFile;
|
|
42
|
+
private timer?;
|
|
43
|
+
constructor(logger: Logger, deps?: CleanupSweepDeps);
|
|
44
|
+
/** Start the interval timer; no-op when disabled or already running. */
|
|
45
|
+
start(): void;
|
|
46
|
+
/** Stop the timer (idempotent). */
|
|
47
|
+
stop(): void;
|
|
48
|
+
/** Run one bounded sweep tick across all configured roots. */
|
|
49
|
+
runOnce(): SweepResult;
|
|
50
|
+
/** @returns false when the maxRunMs deadline was reached mid-scan. */
|
|
51
|
+
private scanRoot;
|
|
52
|
+
/** @returns false when the maxRunMs deadline was reached mid-scan. */
|
|
53
|
+
private scanJobDir;
|
|
54
|
+
private removeDirIfEmpty;
|
|
55
|
+
private sweepEntry;
|
|
56
|
+
/** Inside dedicated roots every regular non-symlink file qualifies — the
|
|
57
|
+
* confinement boundary is the safety criterion, not the file name. */
|
|
58
|
+
private isCandidate;
|
|
59
|
+
private runTick;
|
|
60
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CleanupSweep — OI-15 periodic orphan reaper (TDD §9.2, BRD STORY 3 RD-8).
|
|
3
|
+
*
|
|
4
|
+
* Safety net for artifacts that escaped immediate cleanup (OI-14): crashes,
|
|
5
|
+
* power loss, permission errors. Per tick it scans ONLY the dedicated
|
|
6
|
+
* ARTIFACT_OUTPUT_ROOTS (SECURITY-REVIEW approval condition 2 — never the raw
|
|
7
|
+
* OS temp root, never the agent workspace). Roots are dedicated artifact
|
|
8
|
+
* sinks, so EVERY regular non-symlink file older than the TTL is swept —
|
|
9
|
+
* including job-dir nesting (`<root>/<job>/file`, how markdown-exporter
|
|
10
|
+
* writes) and arbitrary names (`STP-v1-SA4E-380.docx`); filename prefixes
|
|
11
|
+
* are NOT a criterion. Directories, symlinks, young files and anything
|
|
12
|
+
* outside the roots are always kept. Job directories emptied by the sweep
|
|
13
|
+
* are removed too. Each run is hard-bounded by maxRunMs and logs one
|
|
14
|
+
* summary line `{ removed, failed, skipped, durationMs }` with basenames only.
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle: started/stopped next to the health monitor (OrchestrationModule);
|
|
17
|
+
* `enabled: false` never starts a timer.
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
import { artifactOutputRoots } from '../../../config/ContentProxy.js';
|
|
22
|
+
import { loadArtifactCleanupConfig } from './cleanup-config.js';
|
|
23
|
+
export class CleanupSweep {
|
|
24
|
+
logger;
|
|
25
|
+
config;
|
|
26
|
+
roots;
|
|
27
|
+
removeFile;
|
|
28
|
+
timer;
|
|
29
|
+
constructor(logger, deps = {}) {
|
|
30
|
+
this.logger = logger.child({ component: 'CleanupSweep' });
|
|
31
|
+
this.config = { ...loadArtifactCleanupConfig(), ...deps.config };
|
|
32
|
+
this.roots = deps.roots ?? artifactOutputRoots();
|
|
33
|
+
this.removeFile = deps.removeFile ?? ((filePath) => fs.unlinkSync(filePath));
|
|
34
|
+
}
|
|
35
|
+
/** Start the interval timer; no-op when disabled or already running. */
|
|
36
|
+
start() {
|
|
37
|
+
if (!this.config.enabled || this.timer)
|
|
38
|
+
return;
|
|
39
|
+
this.timer = setInterval(() => this.runTick(), this.config.intervalMs);
|
|
40
|
+
this.timer.unref?.();
|
|
41
|
+
this.logger.info({ intervalMs: this.config.intervalMs, ttlMs: this.config.ttlMs, roots: this.roots.length }, 'Artifact cleanup sweep started');
|
|
42
|
+
}
|
|
43
|
+
/** Stop the timer (idempotent). */
|
|
44
|
+
stop() {
|
|
45
|
+
if (!this.timer)
|
|
46
|
+
return;
|
|
47
|
+
clearInterval(this.timer);
|
|
48
|
+
this.timer = undefined;
|
|
49
|
+
this.logger.info('Artifact cleanup sweep stopped');
|
|
50
|
+
}
|
|
51
|
+
/** Run one bounded sweep tick across all configured roots. */
|
|
52
|
+
runOnce() {
|
|
53
|
+
const startedAt = Date.now();
|
|
54
|
+
const deadline = startedAt + this.config.maxRunMs;
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const result = { removed: 0, failed: 0, skipped: 0, durationMs: 0, bounded: false };
|
|
57
|
+
for (const root of this.roots) {
|
|
58
|
+
if (!this.scanRoot(root, now, deadline, result)) {
|
|
59
|
+
result.bounded = true;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
result.durationMs = Date.now() - startedAt;
|
|
64
|
+
this.logger.info({ removed: result.removed, failed: result.failed, skipped: result.skipped, durationMs: result.durationMs }, 'Artifact cleanup sweep tick completed');
|
|
65
|
+
if (result.bounded)
|
|
66
|
+
this.logger.warn({ maxRunMs: this.config.maxRunMs }, 'Artifact cleanup sweep stopped at maxRunMs bound');
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
/** @returns false when the maxRunMs deadline was reached mid-scan. */
|
|
70
|
+
scanRoot(root, now, deadline, result) {
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
this.logger.debug({ err }, 'Artifact cleanup sweep skipped unreadable root');
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (Date.now() >= deadline)
|
|
81
|
+
return false;
|
|
82
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
83
|
+
// Child job dirs nest one level: <root>/<job>/<artifact>. Sweep their
|
|
84
|
+
// files, then remove the dir when the sweep emptied it.
|
|
85
|
+
const jobDir = path.join(root, entry.name);
|
|
86
|
+
if (!this.scanJobDir(jobDir, now, deadline, result))
|
|
87
|
+
return false;
|
|
88
|
+
this.removeDirIfEmpty(jobDir, result);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
this.sweepEntry(root, entry, now, result);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
/** @returns false when the maxRunMs deadline was reached mid-scan. */
|
|
97
|
+
scanJobDir(jobDir, now, deadline, result) {
|
|
98
|
+
let entries;
|
|
99
|
+
try {
|
|
100
|
+
entries = fs.readdirSync(jobDir, { withFileTypes: true });
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
this.logger.debug({ err }, 'Artifact cleanup sweep skipped unreadable job dir');
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (Date.now() >= deadline)
|
|
108
|
+
return false;
|
|
109
|
+
this.sweepEntry(jobDir, entry, now, result);
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
removeDirIfEmpty(jobDir, result) {
|
|
114
|
+
try {
|
|
115
|
+
const remaining = fs.readdirSync(jobDir);
|
|
116
|
+
if (remaining.length === 0) {
|
|
117
|
+
fs.rmdirSync(jobDir);
|
|
118
|
+
result.removed += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Non-empty or locked job dir stays — the next tick re-evaluates.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
sweepEntry(dir, entry, now, result) {
|
|
126
|
+
const filePath = path.join(dir, entry.name);
|
|
127
|
+
if (!this.isCandidate(entry)) {
|
|
128
|
+
result.skipped += 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const stat = fs.lstatSync(filePath);
|
|
133
|
+
if (now - stat.mtimeMs <= this.config.ttlMs) {
|
|
134
|
+
result.skipped += 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.removeFile(filePath);
|
|
138
|
+
result.removed += 1;
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
result.failed += 1;
|
|
142
|
+
this.logger.warn({ artifact: entry.name, err }, 'Artifact cleanup sweep failed to remove file');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Inside dedicated roots every regular non-symlink file qualifies — the
|
|
146
|
+
* confinement boundary is the safety criterion, not the file name. */
|
|
147
|
+
isCandidate(entry) {
|
|
148
|
+
return entry.isFile() && !entry.isSymbolicLink();
|
|
149
|
+
}
|
|
150
|
+
runTick() {
|
|
151
|
+
try {
|
|
152
|
+
this.runOnce();
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
this.logger.error({ err }, 'Artifact cleanup sweep tick failed');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=CleanupSweep.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CleanupSweep.js","sourceRoot":"","sources":["../../../../src/modules/orchestration/artifact-cleanup/CleanupSweep.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact root helpers shared by OI-14 (immediate cleanup, content proxy) and
|
|
3
|
+
* OI-15 (periodic sweep). SECURITY-REVIEW F-01/F-05: local artifact access is
|
|
4
|
+
* confined to the dedicated ARTIFACT_OUTPUT_ROOTS — a path whose real location
|
|
5
|
+
* (symlinks resolved) falls outside them is never read and never deleted.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* F-01 — true only when the candidate's real path (fs.realpathSync, so symlink
|
|
9
|
+
* and `..` tricks are resolved away) sits inside one of the artifact roots.
|
|
10
|
+
* A missing path (ENOENT) or unreadable chain is not within the roots.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isWithinArtifactRoots(candidate: string, roots?: readonly string[]): boolean;
|
|
13
|
+
/** OI-15 — artifact file-name pattern: `export_*`, `render_*`, `generate_*`. */
|
|
14
|
+
export declare function isArtifactFileName(name: string): boolean;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact root helpers shared by OI-14 (immediate cleanup, content proxy) and
|
|
3
|
+
* OI-15 (periodic sweep). SECURITY-REVIEW F-01/F-05: local artifact access is
|
|
4
|
+
* confined to the dedicated ARTIFACT_OUTPUT_ROOTS — a path whose real location
|
|
5
|
+
* (symlinks resolved) falls outside them is never read and never deleted.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { FILE_TOOL_NAME_PREFIXES, artifactOutputRoots, isPathWithinRoot, } from '../../../config/ContentProxy.js';
|
|
10
|
+
/**
|
|
11
|
+
* F-01 — true only when the candidate's real path (fs.realpathSync, so symlink
|
|
12
|
+
* and `..` tricks are resolved away) sits inside one of the artifact roots.
|
|
13
|
+
* A missing path (ENOENT) or unreadable chain is not within the roots.
|
|
14
|
+
*/
|
|
15
|
+
export function isWithinArtifactRoots(candidate, roots = artifactOutputRoots()) {
|
|
16
|
+
let real;
|
|
17
|
+
try {
|
|
18
|
+
real = fs.realpathSync(candidate);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return roots.some((root) => isPathWithinRoot(resolveRoot(root), real));
|
|
24
|
+
}
|
|
25
|
+
/** OI-15 — artifact file-name pattern: `export_*`, `render_*`, `generate_*`. */
|
|
26
|
+
export function isArtifactFileName(name) {
|
|
27
|
+
const lower = name.toLowerCase();
|
|
28
|
+
return FILE_TOOL_NAME_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
|
29
|
+
}
|
|
30
|
+
function resolveRoot(root) {
|
|
31
|
+
try {
|
|
32
|
+
return fs.realpathSync(root);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return path.resolve(root);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=artifact-roots.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"artifact-roots.js","sourceRoot":"","sources":["../../../../src/modules/orchestration/artifact-cleanup/artifact-roots.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAC7B,OAAO,EACL,uBAAuB,EACvB,mBAAmB,EACnB,gBAAgB,GACjB,MAAM,iCAAiC,CAAC;AAEzC;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CACnC,SAAiB,EACjB,QAA2B,mBAAmB,EAAE;IAEhD,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,EAAE,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC;IACpC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,gBAAgB,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;AACzE,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,kBAAkB,CAAC,IAAY;IAC7C,MAAM,KAAK,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;IACjC,OAAO,uBAAuB,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC;AAC5E,CAAC;AAED,SAAS,WAAW,CAAC,IAAY;IAC/B,IAAI,CAAC;QACH,OAAO,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OI-15 — cleanup sweep configuration (TDD §9.2).
|
|
3
|
+
*
|
|
4
|
+
* BRD config keys → environment variables (backend reads config via env, the
|
|
5
|
+
* platform's existing pattern):
|
|
6
|
+
* artifact.cleanup.enabled → ARTIFACT_CLEANUP_ENABLED (default true)
|
|
7
|
+
* artifact.cleanup.intervalMs → ARTIFACT_CLEANUP_INTERVAL_MS (default 300_000)
|
|
8
|
+
* artifact.cleanup.ttlMs → ARTIFACT_CLEANUP_TTL_MS (default 600_000)
|
|
9
|
+
* artifact.cleanup.maxRunMs → ARTIFACT_CLEANUP_MAX_RUN_MS (default 30_000)
|
|
10
|
+
*/
|
|
11
|
+
export interface ArtifactCleanupConfig {
|
|
12
|
+
/** Master switch — false never starts the sweep timer. */
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
/** Sweep cadence (timer-based). */
|
|
15
|
+
intervalMs: number;
|
|
16
|
+
/** Minimum file age before a matching artifact counts as orphaned. */
|
|
17
|
+
ttlMs: number;
|
|
18
|
+
/** Hard bound per sweep run; overrun → stop the tick and log. */
|
|
19
|
+
maxRunMs: number;
|
|
20
|
+
}
|
|
21
|
+
export declare const ARTIFACT_CLEANUP_DEFAULTS: Readonly<ArtifactCleanupConfig>;
|
|
22
|
+
/** Load the cleanup config from the given environment (defaults when unset). */
|
|
23
|
+
export declare function loadArtifactCleanupConfig(env?: NodeJS.ProcessEnv): ArtifactCleanupConfig;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OI-15 — cleanup sweep configuration (TDD §9.2).
|
|
3
|
+
*
|
|
4
|
+
* BRD config keys → environment variables (backend reads config via env, the
|
|
5
|
+
* platform's existing pattern):
|
|
6
|
+
* artifact.cleanup.enabled → ARTIFACT_CLEANUP_ENABLED (default true)
|
|
7
|
+
* artifact.cleanup.intervalMs → ARTIFACT_CLEANUP_INTERVAL_MS (default 300_000)
|
|
8
|
+
* artifact.cleanup.ttlMs → ARTIFACT_CLEANUP_TTL_MS (default 600_000)
|
|
9
|
+
* artifact.cleanup.maxRunMs → ARTIFACT_CLEANUP_MAX_RUN_MS (default 30_000)
|
|
10
|
+
*/
|
|
11
|
+
export const ARTIFACT_CLEANUP_DEFAULTS = Object.freeze({
|
|
12
|
+
enabled: true,
|
|
13
|
+
intervalMs: 300_000,
|
|
14
|
+
ttlMs: 600_000,
|
|
15
|
+
maxRunMs: 30_000,
|
|
16
|
+
});
|
|
17
|
+
/** Load the cleanup config from the given environment (defaults when unset). */
|
|
18
|
+
export function loadArtifactCleanupConfig(env = process.env) {
|
|
19
|
+
return {
|
|
20
|
+
enabled: boolEnv(env['ARTIFACT_CLEANUP_ENABLED'], ARTIFACT_CLEANUP_DEFAULTS.enabled),
|
|
21
|
+
intervalMs: intEnv(env['ARTIFACT_CLEANUP_INTERVAL_MS'], ARTIFACT_CLEANUP_DEFAULTS.intervalMs),
|
|
22
|
+
ttlMs: intEnv(env['ARTIFACT_CLEANUP_TTL_MS'], ARTIFACT_CLEANUP_DEFAULTS.ttlMs),
|
|
23
|
+
maxRunMs: intEnv(env['ARTIFACT_CLEANUP_MAX_RUN_MS'], ARTIFACT_CLEANUP_DEFAULTS.maxRunMs),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function boolEnv(value, fallback) {
|
|
27
|
+
if (value === undefined || value === '')
|
|
28
|
+
return fallback;
|
|
29
|
+
return value === '1' || value.toLowerCase() === 'true';
|
|
30
|
+
}
|
|
31
|
+
function intEnv(value, fallback) {
|
|
32
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
33
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=cleanup-config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cleanup-config.js","sourceRoot":"","sources":["../../../../src/modules/orchestration/artifact-cleanup/cleanup-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAaH,MAAM,CAAC,MAAM,yBAAyB,GAAoC,MAAM,CAAC,MAAM,CAAC;IACtF,OAAO,EAAE,IAAI;IACb,UAAU,EAAE,OAAO;IACnB,KAAK,EAAE,OAAO;IACd,QAAQ,EAAE,MAAM;CACjB,CAAC,CAAC;AAEH,gFAAgF;AAChF,MAAM,UAAU,yBAAyB,CAAC,MAAyB,OAAO,CAAC,GAAG;IAC5E,OAAO;QACL,OAAO,EAAE,OAAO,CAAC,GAAG,CAAC,0BAA0B,CAAC,EAAE,yBAAyB,CAAC,OAAO,CAAC;QACpF,UAAU,EAAE,MAAM,CAAC,GAAG,CAAC,8BAA8B,CAAC,EAAE,yBAAyB,CAAC,UAAU,CAAC;QAC7F,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,yBAAyB,CAAC,EAAE,yBAAyB,CAAC,KAAK,CAAC;QAC9E,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,6BAA6B,CAAC,EAAE,yBAAyB,CAAC,QAAQ,CAAC;KACzF,CAAC;AACJ,CAAC;AAED,SAAS,OAAO,CAAC,KAAyB,EAAE,QAAiB;IAC3D,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE;QAAE,OAAO,QAAQ,CAAC;IACzD,OAAO,KAAK,KAAK,GAAG,IAAI,KAAK,CAAC,WAAW,EAAE,KAAK,MAAM,CAAC;AACzD,CAAC;AAED,SAAS,MAAM,CAAC,KAAyB,EAAE,QAAgB;IACzD,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;IAChD,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC;AACnE,CAAC"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OI-14 — delete-after-inline for local artifacts (TDD §9.1).
|
|
3
|
+
*
|
|
4
|
+
* Contract: after the backend has fully materialised an artifact's bytes, the
|
|
5
|
+
* source file is deleted. Best-effort with identity verification (F-06):
|
|
6
|
+
* - the file is re-stat'ed and only unlinked when inode/size/mtime still match
|
|
7
|
+
* the pre-read snapshot, so a concurrently replaced file is never deleted;
|
|
8
|
+
* - ENOENT counts as success (already gone);
|
|
9
|
+
* - any other failure is logged as a warning and never fails the tool call —
|
|
10
|
+
* the bytes are already delivered and the periodic sweep (OI-15) retries.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
/** Minimal pino-shaped logger so this module stays decoupled from wiring. */
|
|
14
|
+
export interface CleanupLog {
|
|
15
|
+
warn(obj: unknown, msg?: string): void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Delete the artifact at `filePath` after a successful read. `before` is the
|
|
19
|
+
* stat snapshot taken immediately before the read (F-06 identity check).
|
|
20
|
+
*/
|
|
21
|
+
export declare function deleteArtifactAfterRead(filePath: string, before: fs.Stats, log?: CleanupLog): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OI-14 — delete-after-inline for local artifacts (TDD §9.1).
|
|
3
|
+
*
|
|
4
|
+
* Contract: after the backend has fully materialised an artifact's bytes, the
|
|
5
|
+
* source file is deleted. Best-effort with identity verification (F-06):
|
|
6
|
+
* - the file is re-stat'ed and only unlinked when inode/size/mtime still match
|
|
7
|
+
* the pre-read snapshot, so a concurrently replaced file is never deleted;
|
|
8
|
+
* - ENOENT counts as success (already gone);
|
|
9
|
+
* - any other failure is logged as a warning and never fails the tool call —
|
|
10
|
+
* the bytes are already delivered and the periodic sweep (OI-15) retries.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
/**
|
|
15
|
+
* Delete the artifact at `filePath` after a successful read. `before` is the
|
|
16
|
+
* stat snapshot taken immediately before the read (F-06 identity check).
|
|
17
|
+
*/
|
|
18
|
+
export function deleteArtifactAfterRead(filePath, before, log) {
|
|
19
|
+
try {
|
|
20
|
+
const after = fs.lstatSync(filePath);
|
|
21
|
+
if (!identityMatches(before, after)) {
|
|
22
|
+
warn(log, { artifact: path.basename(filePath) }, 'Artifact changed during read; skipped cleanup');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
fs.unlinkSync(filePath);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err?.code === 'ENOENT')
|
|
29
|
+
return; // already gone — success per TDD §9.1
|
|
30
|
+
warn(log, { artifact: path.basename(filePath), err }, 'Artifact cleanup failed after inline');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function identityMatches(before, after) {
|
|
34
|
+
return after.ino === before.ino && after.size === before.size && after.mtimeMs === before.mtimeMs;
|
|
35
|
+
}
|
|
36
|
+
function warn(log, obj, msg) {
|
|
37
|
+
if (log)
|
|
38
|
+
log.warn(obj, msg);
|
|
39
|
+
else
|
|
40
|
+
console.warn(`[artifact-cleanup] ${msg}`, obj);
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=delete-artifact.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"delete-artifact.js","sourceRoot":"","sources":["../../../../src/modules/orchestration/artifact-cleanup/delete-artifact.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAO7B;;;GAGG;AACH,MAAM,UAAU,uBAAuB,CAAC,QAAgB,EAAE,MAAgB,EAAE,GAAgB;IAC1F,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,EAAE,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;QACrC,IAAI,CAAC,eAAe,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC;YACpC,IAAI,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,EAAE,+CAA+C,CAAC,CAAC;YAClG,OAAO;QACT,CAAC;QACD,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;IAC1B,CAAC;IAAC,OAAO,GAAQ,EAAE,CAAC;QAClB,IAAI,GAAG,EAAE,IAAI,KAAK,QAAQ;YAAE,OAAO,CAAC,sCAAsC;QAC1E,IAAI,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,GAAG,EAAE,EAAE,sCAAsC,CAAC,CAAC;IAChG,CAAC;AACH,CAAC;AAED,SAAS,eAAe,CAAC,MAAgB,EAAE,KAAe;IACxD,OAAO,KAAK,CAAC,GAAG,KAAK,MAAM,CAAC,GAAG,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,CAAC,IAAI,IAAI,KAAK,CAAC,OAAO,KAAK,MAAM,CAAC,OAAO,CAAC;AACpG,CAAC;AAED,SAAS,IAAI,CAAC,GAA2B,EAAE,GAAY,EAAE,GAAW;IAClE,IAAI,GAAG;QAAE,GAAG,CAAC,IAAI,CAAC,GAAa,EAAE,GAAG,CAAC,CAAC;;QACjC,OAAO,CAAC,IAAI,CAAC,sBAAsB,GAAG,EAAE,EAAE,GAAG,CAAC,CAAC;AACtD,CAAC"}
|
package/package.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* Overrides exist only for tools that do not follow the convention.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import * as fs from 'fs';
|
|
15
16
|
/** Tool-name prefixes whose result is a file written by the child server. */
|
|
16
17
|
export const FILE_TOOL_NAME_PREFIXES: readonly string[] = ['export_', 'render_', 'generate_'];
|
|
17
18
|
|
|
@@ -48,5 +49,24 @@ export interface ContentProxyOverride {
|
|
|
48
49
|
enabled?: boolean;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/**
|
|
52
|
+
/** Artifact output roots — dedicated sinks for orphaned artifact files. */
|
|
53
|
+
export function artifactOutputRoots(): readonly string[] {
|
|
54
|
+
// Convention: ARTIFACT_OUTPUT_ROOTS env var, or default per-workspace roots
|
|
55
|
+
const env = process.env.ARTIFACT_OUTPUT_ROOTS;
|
|
56
|
+
if (env) return env.split(':').map((s) => s.trim()).filter(Boolean);
|
|
57
|
+
// Default: no roots configured → sweep will find nothing (safe no-op)
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Check whether a real path falls inside one of the artifact roots. */
|
|
62
|
+
export function isPathWithinRoot(root: string, candidate: string): boolean {
|
|
63
|
+
try {
|
|
64
|
+
const real = fs.realpathSync(candidate);
|
|
65
|
+
return artifactOutputRoots().some((r) => real.startsWith(r));
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Per-tool escape hatch for tools that defy the naming/description convention. */
|
|
52
72
|
export const CONTENT_PROXY_OVERRIDES: Record<string, ContentProxyOverride> = {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CleanupSweep — OI-15 periodic orphan reaper (TDD §9.2, BRD STORY 3 RD-8).
|
|
3
|
+
*
|
|
4
|
+
* Safety net for artifacts that escaped immediate cleanup (OI-14): crashes,
|
|
5
|
+
* power loss, permission errors. Per tick it scans ONLY the dedicated
|
|
6
|
+
* ARTIFACT_OUTPUT_ROOTS (SECURITY-REVIEW approval condition 2 — never the raw
|
|
7
|
+
* OS temp root, never the agent workspace). Roots are dedicated artifact
|
|
8
|
+
* sinks, so EVERY regular non-symlink file older than the TTL is swept —
|
|
9
|
+
* including job-dir nesting (`<root>/<job>/file`, how markdown-exporter
|
|
10
|
+
* writes) and arbitrary names (`STP-v1-SA4E-380.docx`); filename prefixes
|
|
11
|
+
* are NOT a criterion. Directories, symlinks, young files and anything
|
|
12
|
+
* outside the roots are always kept. Job directories emptied by the sweep
|
|
13
|
+
* are removed too. Each run is hard-bounded by maxRunMs and logs one
|
|
14
|
+
* summary line `{ removed, failed, skipped, durationMs }` with basenames only.
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle: started/stopped next to the health monitor (OrchestrationModule);
|
|
17
|
+
* `enabled: false` never starts a timer.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Logger } from 'pino';
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import * as path from 'path';
|
|
23
|
+
import { artifactOutputRoots } from '../../../config/ContentProxy.js';
|
|
24
|
+
import { loadArtifactCleanupConfig, type ArtifactCleanupConfig } from './cleanup-config.js';
|
|
25
|
+
|
|
26
|
+
export interface SweepResult {
|
|
27
|
+
removed: number;
|
|
28
|
+
failed: number;
|
|
29
|
+
skipped: number;
|
|
30
|
+
durationMs: number;
|
|
31
|
+
/** True when the tick stopped early because maxRunMs elapsed (UT-48). */
|
|
32
|
+
bounded: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CleanupSweepDeps {
|
|
36
|
+
/** Overrides loaded on top of the env-derived config (tests, ops). */
|
|
37
|
+
config?: Partial<ArtifactCleanupConfig>;
|
|
38
|
+
/** Scan roots override (tests); defaults to the configured artifact roots. */
|
|
39
|
+
roots?: readonly string[];
|
|
40
|
+
/** Deletion hook override (tests); defaults to fs.unlinkSync. */
|
|
41
|
+
removeFile?: (filePath: string) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class CleanupSweep {
|
|
45
|
+
private readonly logger: Logger;
|
|
46
|
+
private readonly config: ArtifactCleanupConfig;
|
|
47
|
+
private readonly roots: readonly string[];
|
|
48
|
+
private readonly removeFile: (filePath: string) => void;
|
|
49
|
+
private timer?: NodeJS.Timeout;
|
|
50
|
+
|
|
51
|
+
constructor(logger: Logger, deps: CleanupSweepDeps = {}) {
|
|
52
|
+
this.logger = logger.child({ component: 'CleanupSweep' });
|
|
53
|
+
this.config = { ...loadArtifactCleanupConfig(), ...deps.config };
|
|
54
|
+
this.roots = deps.roots ?? artifactOutputRoots();
|
|
55
|
+
this.removeFile = deps.removeFile ?? ((filePath) => fs.unlinkSync(filePath));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Start the interval timer; no-op when disabled or already running. */
|
|
59
|
+
start(): void {
|
|
60
|
+
if (!this.config.enabled || this.timer) return;
|
|
61
|
+
this.timer = setInterval(() => this.runTick(), this.config.intervalMs);
|
|
62
|
+
this.timer.unref?.();
|
|
63
|
+
this.logger.info(
|
|
64
|
+
{ intervalMs: this.config.intervalMs, ttlMs: this.config.ttlMs, roots: this.roots.length },
|
|
65
|
+
'Artifact cleanup sweep started',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Stop the timer (idempotent). */
|
|
70
|
+
stop(): void {
|
|
71
|
+
if (!this.timer) return;
|
|
72
|
+
clearInterval(this.timer);
|
|
73
|
+
this.timer = undefined;
|
|
74
|
+
this.logger.info('Artifact cleanup sweep stopped');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Run one bounded sweep tick across all configured roots. */
|
|
78
|
+
runOnce(): SweepResult {
|
|
79
|
+
const startedAt = Date.now();
|
|
80
|
+
const deadline = startedAt + this.config.maxRunMs;
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const result: SweepResult = { removed: 0, failed: 0, skipped: 0, durationMs: 0, bounded: false };
|
|
83
|
+
|
|
84
|
+
for (const root of this.roots) {
|
|
85
|
+
if (!this.scanRoot(root, now, deadline, result)) {
|
|
86
|
+
result.bounded = true;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
result.durationMs = Date.now() - startedAt;
|
|
92
|
+
this.logger.info(
|
|
93
|
+
{ removed: result.removed, failed: result.failed, skipped: result.skipped, durationMs: result.durationMs },
|
|
94
|
+
'Artifact cleanup sweep tick completed',
|
|
95
|
+
);
|
|
96
|
+
if (result.bounded) this.logger.warn({ maxRunMs: this.config.maxRunMs }, 'Artifact cleanup sweep stopped at maxRunMs bound');
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @returns false when the maxRunMs deadline was reached mid-scan. */
|
|
101
|
+
private scanRoot(root: string, now: number, deadline: number, result: SweepResult): boolean {
|
|
102
|
+
let entries: fs.Dirent[];
|
|
103
|
+
try {
|
|
104
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
this.logger.debug({ err }, 'Artifact cleanup sweep skipped unreadable root');
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (Date.now() >= deadline) return false;
|
|
111
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
112
|
+
// Child job dirs nest one level: <root>/<job>/<artifact>. Sweep their
|
|
113
|
+
// files, then remove the dir when the sweep emptied it.
|
|
114
|
+
const jobDir = path.join(root, entry.name);
|
|
115
|
+
if (!this.scanJobDir(jobDir, now, deadline, result)) return false;
|
|
116
|
+
this.removeDirIfEmpty(jobDir, result);
|
|
117
|
+
} else {
|
|
118
|
+
this.sweepEntry(root, entry, now, result);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @returns false when the maxRunMs deadline was reached mid-scan. */
|
|
125
|
+
private scanJobDir(jobDir: string, now: number, deadline: number, result: SweepResult): boolean {
|
|
126
|
+
let entries: fs.Dirent[];
|
|
127
|
+
try {
|
|
128
|
+
entries = fs.readdirSync(jobDir, { withFileTypes: true });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
this.logger.debug({ err }, 'Artifact cleanup sweep skipped unreadable job dir');
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (Date.now() >= deadline) return false;
|
|
135
|
+
this.sweepEntry(jobDir, entry, now, result);
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private removeDirIfEmpty(jobDir: string, result: SweepResult): void {
|
|
141
|
+
try {
|
|
142
|
+
const remaining = fs.readdirSync(jobDir);
|
|
143
|
+
if (remaining.length === 0) {
|
|
144
|
+
fs.rmdirSync(jobDir);
|
|
145
|
+
result.removed += 1;
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// Non-empty or locked job dir stays — the next tick re-evaluates.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private sweepEntry(dir: string, entry: fs.Dirent, now: number, result: SweepResult): void {
|
|
153
|
+
const filePath = path.join(dir, entry.name);
|
|
154
|
+
if (!this.isCandidate(entry)) {
|
|
155
|
+
result.skipped += 1;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const stat = fs.lstatSync(filePath);
|
|
160
|
+
if (now - stat.mtimeMs <= this.config.ttlMs) {
|
|
161
|
+
result.skipped += 1;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.removeFile(filePath);
|
|
165
|
+
result.removed += 1;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
result.failed += 1;
|
|
168
|
+
this.logger.warn({ artifact: entry.name, err }, 'Artifact cleanup sweep failed to remove file');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Inside dedicated roots every regular non-symlink file qualifies — the
|
|
173
|
+
* confinement boundary is the safety criterion, not the file name. */
|
|
174
|
+
private isCandidate(entry: fs.Dirent): boolean {
|
|
175
|
+
return entry.isFile() && !entry.isSymbolicLink();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private runTick(): void {
|
|
179
|
+
try {
|
|
180
|
+
this.runOnce();
|
|
181
|
+
} catch (err) {
|
|
182
|
+
this.logger.error({ err }, 'Artifact cleanup sweep tick failed');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CleanupSweep — OI-15 unit tests (STC §2.9 addendum, TDD §9.2).
|
|
3
|
+
* Real temp scan roots; injected removeFile for bound/failure cases.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
7
|
+
import type { Logger } from 'pino';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import { CleanupSweep, type SweepResult } from '../CleanupSweep.js';
|
|
12
|
+
import { ARTIFACT_CLEANUP_DEFAULTS, loadArtifactCleanupConfig } from '../cleanup-config.js';
|
|
13
|
+
import { artifactOutputRoots } from '../../../../config/ContentProxy.js';
|
|
14
|
+
|
|
15
|
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'cleanup-sweep-'));
|
|
16
|
+
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); });
|
|
17
|
+
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
|
18
|
+
|
|
19
|
+
function fakeLogger() {
|
|
20
|
+
const calls = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
21
|
+
const logger: any = { ...calls, child: () => logger };
|
|
22
|
+
return { logger: logger as unknown as Logger, calls };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function freshRoot(name: string): string {
|
|
26
|
+
const root = path.join(TMP, name);
|
|
27
|
+
fs.mkdirSync(root, { recursive: true });
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function seedOld(root: string, name: string, ageMs: number): string {
|
|
32
|
+
const file = path.join(root, name);
|
|
33
|
+
fs.writeFileSync(file, Buffer.from(`orphan:${name}`));
|
|
34
|
+
const past = new Date(Date.now() - ageMs);
|
|
35
|
+
fs.utimesSync(file, past, past);
|
|
36
|
+
return file;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const TINY = { ttlMs: 60_000, intervalMs: 300_000, maxRunMs: 30_000, enabled: true };
|
|
40
|
+
|
|
41
|
+
// STC: UT-47 — sweep removes orphans older than TTL (any name, incl. nested
|
|
42
|
+
// job dirs); keeps young files, symlinks and everything outside the roots
|
|
43
|
+
describe('CleanupSweep — TTL and pattern (UT-47)', () => {
|
|
44
|
+
it('removes old artifact files (any name, incl. nested job dirs) and keeps young and symlinked files', () => {
|
|
45
|
+
const root = freshRoot('ttl');
|
|
46
|
+
const removed = seedOld(root, 'export_r1.docx', 120_000);
|
|
47
|
+
const young = seedOld(root, 'export_r2.docx', 1_000);
|
|
48
|
+
// Real-world garbage: arbitrary names inside the dedicated root ARE orphans
|
|
49
|
+
const legacyDoc = seedOld(root, 'STP-v1-SA4E-380.docx', 120_000);
|
|
50
|
+
// Child job-dir nesting: <root>/<job>/<artifact>. Job dir holding a young
|
|
51
|
+
// file stays; a job dir emptied by the sweep is removed too.
|
|
52
|
+
const jobDir = path.join(root, 'a1b2c3d4e5f6');
|
|
53
|
+
fs.mkdirSync(jobDir, { recursive: true });
|
|
54
|
+
const nested = seedOld(jobDir, 'SECURITY-REVIEW-v1-SA4E-380.docx', 120_000);
|
|
55
|
+
const nestedYoung = seedOld(jobDir, 'fresh.docx', 1_000);
|
|
56
|
+
const jobDir2 = path.join(root, 'b2c3d4e5f6a1');
|
|
57
|
+
fs.mkdirSync(jobDir2, { recursive: true });
|
|
58
|
+
const nestedOnlyOld = seedOld(jobDir2, 'export_json-output.json', 120_000);
|
|
59
|
+
const symlink = path.join(root, 'export_l.docx');
|
|
60
|
+
let hasSymlink = true;
|
|
61
|
+
try {
|
|
62
|
+
fs.symlinkSync(young, symlink, 'file'); // target stays — dangling links would fake ENOENT
|
|
63
|
+
} catch {
|
|
64
|
+
hasSymlink = false; // Windows without symlink privilege — guard covered by isFile() filter
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { logger, calls } = fakeLogger();
|
|
68
|
+
const result = new CleanupSweep(logger, { roots: [root], config: TINY }).runOnce();
|
|
69
|
+
|
|
70
|
+
expect(fs.existsSync(removed)).toBe(false);
|
|
71
|
+
expect(fs.existsSync(legacyDoc)).toBe(false);
|
|
72
|
+
expect(fs.existsSync(nested)).toBe(false);
|
|
73
|
+
expect(fs.existsSync(nestedOnlyOld)).toBe(false);
|
|
74
|
+
expect(fs.existsSync(jobDir2)).toBe(false); // emptied by the sweep → dir removed
|
|
75
|
+
expect(fs.existsSync(jobDir)).toBe(true); // still holds the young file
|
|
76
|
+
expect(fs.existsSync(young)).toBe(true);
|
|
77
|
+
expect(fs.existsSync(nestedYoung)).toBe(true);
|
|
78
|
+
if (hasSymlink) expect(fs.existsSync(symlink)).toBe(true);
|
|
79
|
+
// removed: export_r1 + STP + nested SECURITY-REVIEW + nested json + emptied job dir = 5
|
|
80
|
+
expect(result.removed).toBe(5);
|
|
81
|
+
expect(result.failed).toBe(0);
|
|
82
|
+
|
|
83
|
+
const summary = calls.info.mock.calls.find(([, msg]) => msg === 'Artifact cleanup sweep tick completed');
|
|
84
|
+
expect(summary).toBeDefined();
|
|
85
|
+
expect(summary![0]).toMatchObject({ removed: 5, failed: 0, skipped: result.skipped });
|
|
86
|
+
expect(JSON.stringify(summary![0])).not.toContain(root); // I-3: no host paths in logs
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('never scans outside the configured roots (agent workspace untouched)', () => {
|
|
90
|
+
const root = freshRoot('confined');
|
|
91
|
+
const workspace = freshRoot('workspace');
|
|
92
|
+
const workspaceArtifact = seedOld(workspace, 'export_w.docx', 120_000);
|
|
93
|
+
seedOld(root, 'export_root.docx', 120_000);
|
|
94
|
+
|
|
95
|
+
const result = new CleanupSweep(fakeLogger().logger, { roots: [root], config: TINY }).runOnce();
|
|
96
|
+
|
|
97
|
+
expect(fs.existsSync(workspaceArtifact)).toBe(true);
|
|
98
|
+
expect(result.removed).toBe(1);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// STC: UT-48 — sweep bounds and configuration
|
|
103
|
+
describe('CleanupSweep — bounds and configuration (UT-48)', () => {
|
|
104
|
+
it('stops the tick at maxRunMs and reports partial deletions honestly', () => {
|
|
105
|
+
const root = freshRoot('bound');
|
|
106
|
+
const total = 12;
|
|
107
|
+
for (let i = 0; i < total; i += 1) seedOld(root, `export_b${i}.docx`, 120_000);
|
|
108
|
+
const slowRemove = (filePath: string) => {
|
|
109
|
+
const until = Date.now() + 8; // simulate slow unlink without async
|
|
110
|
+
while (Date.now() < until) { /* busy */ }
|
|
111
|
+
fs.unlinkSync(filePath);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const { logger, calls } = fakeLogger();
|
|
115
|
+
const result = new CleanupSweep(logger, { roots: [root], config: { ...TINY, maxRunMs: 20 }, removeFile: slowRemove }).runOnce();
|
|
116
|
+
|
|
117
|
+
expect(result.bounded).toBe(true);
|
|
118
|
+
expect(result.removed).toBeGreaterThan(0);
|
|
119
|
+
expect(result.removed).toBeLessThan(total);
|
|
120
|
+
expect(calls.warn).toHaveBeenCalledWith({ maxRunMs: 20 }, 'Artifact cleanup sweep stopped at maxRunMs bound');
|
|
121
|
+
expect(result.durationMs).toBeLessThan(10_000);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('continues after a failure and lists it with the basename only', () => {
|
|
125
|
+
const root = freshRoot('failure');
|
|
126
|
+
const locked = seedOld(root, 'export_locked.docx', 120_000);
|
|
127
|
+
seedOld(root, 'export_ok.docx', 120_000);
|
|
128
|
+
const removeFile = (filePath: string) => {
|
|
129
|
+
if (filePath === locked) throw Object.assign(new Error('EPERM'), { code: 'EPERM' });
|
|
130
|
+
fs.unlinkSync(filePath);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const { logger, calls } = fakeLogger();
|
|
134
|
+
const result = new CleanupSweep(logger, { roots: [root], config: TINY, removeFile }).runOnce();
|
|
135
|
+
|
|
136
|
+
expect(result).toMatchObject({ removed: 1, failed: 1, bounded: false });
|
|
137
|
+
expect(calls.warn).toHaveBeenCalledWith(
|
|
138
|
+
expect.objectContaining({ artifact: 'export_locked.docx' }),
|
|
139
|
+
'Artifact cleanup sweep failed to remove file',
|
|
140
|
+
);
|
|
141
|
+
expect(JSON.stringify(calls.warn.mock.calls.at(-1)?.[0])).not.toContain(root);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('does not start a timer when enabled=false', () => {
|
|
145
|
+
const sweep = new CleanupSweep(fakeLogger().logger, { config: { enabled: false } });
|
|
146
|
+
sweep.start();
|
|
147
|
+
expect((sweep as any).timer).toBeUndefined();
|
|
148
|
+
sweep.stop();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('runs on the configured interval once started and stops cleanly', () => {
|
|
152
|
+
vi.useFakeTimers();
|
|
153
|
+
const sweep = new CleanupSweep(fakeLogger().logger, { config: { ...TINY, intervalMs: 10 } });
|
|
154
|
+
const runOnce = vi.spyOn(sweep, 'runOnce').mockReturnValue({ removed: 0, failed: 0, skipped: 0, durationMs: 0, bounded: false } as SweepResult);
|
|
155
|
+
|
|
156
|
+
sweep.start();
|
|
157
|
+
vi.advanceTimersByTime(35);
|
|
158
|
+
expect(runOnce.mock.calls.length).toBeGreaterThanOrEqual(3);
|
|
159
|
+
|
|
160
|
+
sweep.stop();
|
|
161
|
+
vi.advanceTimersByTime(50);
|
|
162
|
+
expect(runOnce.mock.calls.length).toBe(3);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('cleanup configuration — env mapping (UT-48 step 3)', () => {
|
|
167
|
+
it('ships the documented defaults', () => {
|
|
168
|
+
expect(ARTIFACT_CLEANUP_DEFAULTS).toEqual({ enabled: true, intervalMs: 300_000, ttlMs: 600_000, maxRunMs: 30_000 });
|
|
169
|
+
expect(loadArtifactCleanupConfig({})).toEqual(ARTIFACT_CLEANUP_DEFAULTS);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('applies env overrides for enabled/interval/ttl/maxRun', () => {
|
|
173
|
+
expect(loadArtifactCleanupConfig({
|
|
174
|
+
ARTIFACT_CLEANUP_ENABLED: 'false',
|
|
175
|
+
ARTIFACT_CLEANUP_INTERVAL_MS: '1000',
|
|
176
|
+
ARTIFACT_CLEANUP_TTL_MS: '50',
|
|
177
|
+
ARTIFACT_CLEANUP_MAX_RUN_MS: '10',
|
|
178
|
+
})).toEqual({ enabled: false, intervalMs: 1000, ttlMs: 50, maxRunMs: 10 });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('defaults the roots to the dedicated artifact directories plus the child artifact home', () => {
|
|
182
|
+
const workspace = path.resolve(process.cwd());
|
|
183
|
+
const roots = artifactOutputRoots({});
|
|
184
|
+
expect(roots).toContain(path.join(os.tmpdir(), 'sa4e-artifacts'));
|
|
185
|
+
expect(roots).toContain(path.join(workspace, 'tmp', 'artifacts'));
|
|
186
|
+
// SA4E-380 F-01: the markdown-exporter child's own artifact home must be
|
|
187
|
+
// in the defaults, otherwise every local-path read fails confinement.
|
|
188
|
+
const childHome = process.platform === 'win32'
|
|
189
|
+
? path.join(process.env.LOCALAPPDATA ?? '', 'markdown-exporter-mcp', 'artifacts')
|
|
190
|
+
: path.join(process.env.HOME ?? os.homedir(), '.cache', 'markdown-exporter-mcp', 'artifacts');
|
|
191
|
+
expect(roots).toContain(path.resolve(childHome));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('honours the ARTIFACT_OUTPUT_ROOTS env override (path.delimiter separated)', () => {
|
|
195
|
+
const a = path.join(TMP, 'override-a');
|
|
196
|
+
const b = path.join(TMP, 'override-b');
|
|
197
|
+
expect(artifactOutputRoots({ ARTIFACT_OUTPUT_ROOTS: [a, b].join(path.delimiter) })).toEqual([a, b]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact root helpers shared by OI-14 (immediate cleanup, content proxy) and
|
|
3
|
+
* OI-15 (periodic sweep). SECURITY-REVIEW F-01/F-05: local artifact access is
|
|
4
|
+
* confined to the dedicated ARTIFACT_OUTPUT_ROOTS — a path whose real location
|
|
5
|
+
* (symlinks resolved) falls outside them is never read and never deleted.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import {
|
|
11
|
+
FILE_TOOL_NAME_PREFIXES,
|
|
12
|
+
artifactOutputRoots,
|
|
13
|
+
isPathWithinRoot,
|
|
14
|
+
} from '../../../config/ContentProxy.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* F-01 — true only when the candidate's real path (fs.realpathSync, so symlink
|
|
18
|
+
* and `..` tricks are resolved away) sits inside one of the artifact roots.
|
|
19
|
+
* A missing path (ENOENT) or unreadable chain is not within the roots.
|
|
20
|
+
*/
|
|
21
|
+
export function isWithinArtifactRoots(
|
|
22
|
+
candidate: string,
|
|
23
|
+
roots: readonly string[] = artifactOutputRoots(),
|
|
24
|
+
): boolean {
|
|
25
|
+
let real: string;
|
|
26
|
+
try {
|
|
27
|
+
real = fs.realpathSync(candidate);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return roots.some((root) => isPathWithinRoot(resolveRoot(root), real));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** OI-15 — artifact file-name pattern: `export_*`, `render_*`, `generate_*`. */
|
|
35
|
+
export function isArtifactFileName(name: string): boolean {
|
|
36
|
+
const lower = name.toLowerCase();
|
|
37
|
+
return FILE_TOOL_NAME_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveRoot(root: string): string {
|
|
41
|
+
try {
|
|
42
|
+
return fs.realpathSync(root);
|
|
43
|
+
} catch {
|
|
44
|
+
return path.resolve(root);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OI-15 — cleanup sweep configuration (TDD §9.2).
|
|
3
|
+
*
|
|
4
|
+
* BRD config keys → environment variables (backend reads config via env, the
|
|
5
|
+
* platform's existing pattern):
|
|
6
|
+
* artifact.cleanup.enabled → ARTIFACT_CLEANUP_ENABLED (default true)
|
|
7
|
+
* artifact.cleanup.intervalMs → ARTIFACT_CLEANUP_INTERVAL_MS (default 300_000)
|
|
8
|
+
* artifact.cleanup.ttlMs → ARTIFACT_CLEANUP_TTL_MS (default 600_000)
|
|
9
|
+
* artifact.cleanup.maxRunMs → ARTIFACT_CLEANUP_MAX_RUN_MS (default 30_000)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ArtifactCleanupConfig {
|
|
13
|
+
/** Master switch — false never starts the sweep timer. */
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
/** Sweep cadence (timer-based). */
|
|
16
|
+
intervalMs: number;
|
|
17
|
+
/** Minimum file age before a matching artifact counts as orphaned. */
|
|
18
|
+
ttlMs: number;
|
|
19
|
+
/** Hard bound per sweep run; overrun → stop the tick and log. */
|
|
20
|
+
maxRunMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const ARTIFACT_CLEANUP_DEFAULTS: Readonly<ArtifactCleanupConfig> = Object.freeze({
|
|
24
|
+
enabled: true,
|
|
25
|
+
intervalMs: 300_000,
|
|
26
|
+
ttlMs: 600_000,
|
|
27
|
+
maxRunMs: 30_000,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Load the cleanup config from the given environment (defaults when unset). */
|
|
31
|
+
export function loadArtifactCleanupConfig(env: NodeJS.ProcessEnv = process.env): ArtifactCleanupConfig {
|
|
32
|
+
return {
|
|
33
|
+
enabled: boolEnv(env['ARTIFACT_CLEANUP_ENABLED'], ARTIFACT_CLEANUP_DEFAULTS.enabled),
|
|
34
|
+
intervalMs: intEnv(env['ARTIFACT_CLEANUP_INTERVAL_MS'], ARTIFACT_CLEANUP_DEFAULTS.intervalMs),
|
|
35
|
+
ttlMs: intEnv(env['ARTIFACT_CLEANUP_TTL_MS'], ARTIFACT_CLEANUP_DEFAULTS.ttlMs),
|
|
36
|
+
maxRunMs: intEnv(env['ARTIFACT_CLEANUP_MAX_RUN_MS'], ARTIFACT_CLEANUP_DEFAULTS.maxRunMs),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function boolEnv(value: string | undefined, fallback: boolean): boolean {
|
|
41
|
+
if (value === undefined || value === '') return fallback;
|
|
42
|
+
return value === '1' || value.toLowerCase() === 'true';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function intEnv(value: string | undefined, fallback: number): number {
|
|
46
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
47
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
48
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OI-14 — delete-after-inline for local artifacts (TDD §9.1).
|
|
3
|
+
*
|
|
4
|
+
* Contract: after the backend has fully materialised an artifact's bytes, the
|
|
5
|
+
* source file is deleted. Best-effort with identity verification (F-06):
|
|
6
|
+
* - the file is re-stat'ed and only unlinked when inode/size/mtime still match
|
|
7
|
+
* the pre-read snapshot, so a concurrently replaced file is never deleted;
|
|
8
|
+
* - ENOENT counts as success (already gone);
|
|
9
|
+
* - any other failure is logged as a warning and never fails the tool call —
|
|
10
|
+
* the bytes are already delivered and the periodic sweep (OI-15) retries.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
|
|
16
|
+
/** Minimal pino-shaped logger so this module stays decoupled from wiring. */
|
|
17
|
+
export interface CleanupLog {
|
|
18
|
+
warn(obj: unknown, msg?: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Delete the artifact at `filePath` after a successful read. `before` is the
|
|
23
|
+
* stat snapshot taken immediately before the read (F-06 identity check).
|
|
24
|
+
*/
|
|
25
|
+
export function deleteArtifactAfterRead(filePath: string, before: fs.Stats, log?: CleanupLog): void {
|
|
26
|
+
try {
|
|
27
|
+
const after = fs.lstatSync(filePath);
|
|
28
|
+
if (!identityMatches(before, after)) {
|
|
29
|
+
warn(log, { artifact: path.basename(filePath) }, 'Artifact changed during read; skipped cleanup');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
fs.unlinkSync(filePath);
|
|
33
|
+
} catch (err: any) {
|
|
34
|
+
if (err?.code === 'ENOENT') return; // already gone — success per TDD §9.1
|
|
35
|
+
warn(log, { artifact: path.basename(filePath), err }, 'Artifact cleanup failed after inline');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function identityMatches(before: fs.Stats, after: fs.Stats): boolean {
|
|
40
|
+
return after.ino === before.ino && after.size === before.size && after.mtimeMs === before.mtimeMs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function warn(log: CleanupLog | undefined, obj: unknown, msg: string): void {
|
|
44
|
+
if (log) log.warn(obj as object, msg);
|
|
45
|
+
else console.warn(`[artifact-cleanup] ${msg}`, obj);
|
|
46
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content proxy — local cleanup suite (SA4E-380 STC §2.9 addendum, TDD §9.1).
|
|
3
|
+
* UT-44/45/46 + the F-01 confinement contract for `readLocalFile`.
|
|
4
|
+
* Split from content-proxy.test.ts to honour the 200-line file gate.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, afterAll, vi } from 'vitest';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import { pathToFileURL } from 'url';
|
|
12
|
+
import { detectCapability } from '../detect.js';
|
|
13
|
+
import { adaptInvocation } from '../invocation-adapter.js';
|
|
14
|
+
import { adaptResult } from '../result-adapter.js';
|
|
15
|
+
import { resolveArtifact, resolveContentBlock } from '../artifact-source.js';
|
|
16
|
+
import type { ToolDefinition } from '../../../../types/tool.js';
|
|
17
|
+
|
|
18
|
+
// ESM namespaces are not spy-able — route unlinkSync through a mutable ref so
|
|
19
|
+
// the UT-45 failure case can simulate an undeletable (locked) artifact.
|
|
20
|
+
const unlinkRef = vi.hoisted(() => ({ impl: null as null | (() => never) }));
|
|
21
|
+
vi.mock('fs', async (importOriginal) => {
|
|
22
|
+
const actual = await importOriginal<typeof import('fs')>();
|
|
23
|
+
const real = actual.unlinkSync.bind(actual);
|
|
24
|
+
const unlinkSync = ((filePath: Parameters<typeof real>[0]) => {
|
|
25
|
+
if (unlinkRef.impl) return unlinkRef.impl(filePath);
|
|
26
|
+
return real(filePath);
|
|
27
|
+
}) as typeof actual.unlinkSync;
|
|
28
|
+
return { ...actual, unlinkSync };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'content-proxy-cleanup-'));
|
|
32
|
+
const PREVIOUS_ROOTS = process.env['ARTIFACT_OUTPUT_ROOTS'];
|
|
33
|
+
process.env['ARTIFACT_OUTPUT_ROOTS'] = TMP;
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
unlinkRef.impl = null;
|
|
36
|
+
if (PREVIOUS_ROOTS === undefined) delete process.env['ARTIFACT_OUTPUT_ROOTS'];
|
|
37
|
+
else process.env['ARTIFACT_OUTPUT_ROOTS'] = PREVIOUS_ROOTS;
|
|
38
|
+
fs.rmSync(TMP, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const EXPORT_DOCX: ToolDefinition = {
|
|
42
|
+
name: 'export_docx',
|
|
43
|
+
description: 'Export a Markdown file to DOCX.',
|
|
44
|
+
category: 'orchestration',
|
|
45
|
+
inputSchema: { type: 'object', properties: { markdown: { type: 'string' } }, required: ['markdown'] },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const wrapperInvocation = () => adaptInvocation(detectCapability(EXPORT_DOCX)!, {
|
|
49
|
+
content_base64: Buffer.from('# x').toString('base64'),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('content proxy — local cleanup (OI-14)', () => {
|
|
53
|
+
// STC: UT-44 — immediate cleanup: local artifact deleted after successful inline
|
|
54
|
+
it('deletes the local artifact after its bytes are inlined (stat → ENOENT)', async () => {
|
|
55
|
+
const file = path.join(TMP, 'out.docx');
|
|
56
|
+
fs.writeFileSync(file, Buffer.from('DOCX-BYTES'));
|
|
57
|
+
|
|
58
|
+
const fetched = await resolveArtifact({ path: file, name: 'out.docx' }, {});
|
|
59
|
+
|
|
60
|
+
expect(fetched?.buffer.toString()).toBe('DOCX-BYTES');
|
|
61
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// STC: UT-44 step 3 — a file: URI read inherits deletion via readLocalFile
|
|
65
|
+
it('deletes artifacts read through a file: URI as well', async () => {
|
|
66
|
+
const file = path.join(TMP, 'cleanup-uri.docx');
|
|
67
|
+
fs.writeFileSync(file, Buffer.from('URI-BYTES'));
|
|
68
|
+
|
|
69
|
+
const fetched = await resolveArtifact({ uri: pathToFileURL(file).href, name: 'cleanup-uri.docx' }, {});
|
|
70
|
+
|
|
71
|
+
expect(fetched?.buffer.toString()).toBe('URI-BYTES');
|
|
72
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// STC: UT-45 — cleanup failure never fails the tool call; basename-only warning
|
|
76
|
+
it('keeps the bytes and warns (basename only) when unlink fails', async () => {
|
|
77
|
+
const file = path.join(TMP, 'locked.docx');
|
|
78
|
+
fs.writeFileSync(file, Buffer.from('LOCKED-BYTES'));
|
|
79
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
80
|
+
unlinkRef.impl = () => {
|
|
81
|
+
throw Object.assign(new Error('EBUSY: resource busy'), { code: 'EBUSY' });
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
const fetched = await resolveArtifact({ path: file, name: 'locked.docx' }, {});
|
|
85
|
+
|
|
86
|
+
expect(fetched?.buffer.toString()).toBe('LOCKED-BYTES');
|
|
87
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
88
|
+
const logged = (warn.mock.calls.at(-1) ?? []).map((arg) => JSON.stringify(arg)).join(' ');
|
|
89
|
+
expect(logged).toContain('locked.docx');
|
|
90
|
+
expect(logged).not.toContain(TMP); // I-3: no host path in logs
|
|
91
|
+
} finally {
|
|
92
|
+
unlinkRef.impl = null;
|
|
93
|
+
warn.mockRestore();
|
|
94
|
+
fs.rmSync(file, { force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// STC: UT-45 step 2 — a failed read never deletes anything
|
|
99
|
+
it('never deletes when the read fails (out-of-root or oversized)', async () => {
|
|
100
|
+
const outside = path.join(os.tmpdir(), `content-proxy-refused-${Date.now()}.docx`);
|
|
101
|
+
fs.writeFileSync(outside, Buffer.from('SECRET'));
|
|
102
|
+
const oversized = path.join(TMP, 'huge.docx');
|
|
103
|
+
fs.writeFileSync(oversized, Buffer.alloc(26 * 1024 * 1024, 'x'));
|
|
104
|
+
try {
|
|
105
|
+
await resolveArtifact({ path: outside, name: 'secret.docx' }, {});
|
|
106
|
+
await resolveArtifact({ path: oversized, name: 'huge.docx' }, {});
|
|
107
|
+
expect(fs.existsSync(outside)).toBe(true);
|
|
108
|
+
expect(fs.existsSync(oversized)).toBe(true);
|
|
109
|
+
} finally {
|
|
110
|
+
fs.rmSync(outside, { force: true });
|
|
111
|
+
fs.rmSync(oversized, { force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// STC: UT-46 — resource/URL/blob-resolved artifacts are never deleted (OI-14 scope)
|
|
116
|
+
it('never deletes artifacts resolved via resources/read, URLs or content blocks', async () => {
|
|
117
|
+
const cleanup = vi.fn();
|
|
118
|
+
const readResource = async (uri: string) => (uri === 'artifact://job-7/UG.docx' ? Buffer.from('REMOTE-DOCX') : null);
|
|
119
|
+
const fetchUrl = async () => Buffer.from('HTTP-DOCX');
|
|
120
|
+
|
|
121
|
+
await resolveArtifact({ uri: 'artifact://job-7/UG.docx', name: 'UG.docx' }, { cleanup, readResource });
|
|
122
|
+
await resolveArtifact({ url: 'https://files.example/UG.docx', name: 'UG.docx' }, { cleanup, fetchUrl });
|
|
123
|
+
await resolveContentBlock(
|
|
124
|
+
{ type: 'resource', resource: { uri: 'file:///srv/UG.docx', blob: Buffer.from('BLOB-DOCX').toString('base64') } },
|
|
125
|
+
{ cleanup },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(cleanup).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Dep contract behind the McpClientManager wiring: local reads call cleanup with the pre-read stat.
|
|
132
|
+
it('invokes the injected cleanup hook only for local reads', async () => {
|
|
133
|
+
const cleanup = vi.fn();
|
|
134
|
+
const file = path.join(TMP, 'hooked.docx');
|
|
135
|
+
fs.writeFileSync(file, Buffer.from('HOOKED-BYTES'));
|
|
136
|
+
|
|
137
|
+
await resolveArtifact({ path: file, name: 'hooked.docx' }, { cleanup });
|
|
138
|
+
|
|
139
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
140
|
+
expect(cleanup).toHaveBeenCalledWith(file, expect.objectContaining({ size: 12 }));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// SECURITY-REVIEW F-01 (High, binding): outside ARTIFACT_OUTPUT_ROOTS → no read, no delete, loud failure.
|
|
144
|
+
it('refuses out-of-root local paths and fails loudly as ARTIFACT_FETCH_FAILED', async () => {
|
|
145
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'content-proxy-outside-'));
|
|
146
|
+
try {
|
|
147
|
+
const file = path.join(outside, 'secret.docx');
|
|
148
|
+
fs.writeFileSync(file, Buffer.from('SECRET'));
|
|
149
|
+
const result = {
|
|
150
|
+
content: [{ type: 'text', text: JSON.stringify({ success: true, artifacts: [{ path: file, name: 'secret.docx', size: 6 }] }) }],
|
|
151
|
+
isError: false,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const fetched = await resolveArtifact({ path: file, name: 'secret.docx' }, {});
|
|
155
|
+
const adapted = await adaptResult(wrapperInvocation(), result, { serverName: 'local-exporter' });
|
|
156
|
+
|
|
157
|
+
expect(fetched).toBeNull(); // never read
|
|
158
|
+
expect(fs.existsSync(file)).toBe(true); // never deleted
|
|
159
|
+
expect(adapted.isError).toBe(true);
|
|
160
|
+
expect(adapted.content[0].text).toMatch(/ARTIFACT_FETCH_FAILED/);
|
|
161
|
+
expect(adapted.content[0].text).not.toContain(outside); // no host path leak
|
|
162
|
+
} finally {
|
|
163
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|