@tonyclaw/agent-inspector 3.0.29 → 3.0.31
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/.output/cli.js +791 -191
- package/.output/nitro.json +1 -1
- package/.output/public/assets/CompareDrawer-DB2KggLE.js +1 -0
- package/.output/public/assets/InspectorPet-CfwDw6kD.js +4108 -0
- package/.output/public/assets/ProxyViewerContainer-PcrO96_F.js +126 -0
- package/.output/public/assets/{ReplayDialog-CctnITbt.js → ReplayDialog-3Pl7EKjp.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-BrN0RBbL.js → RequestAnatomy-BcPjcHLX.js} +1 -1
- package/.output/public/assets/ResponseView-DOx51wV3.js +2 -0
- package/.output/public/assets/StreamingChunkSequence-LkYJWM1A.js +1 -0
- package/.output/public/assets/_sessionId-BSg32vC6.js +1 -0
- package/.output/public/assets/{_sessionId-DKM91udC.js → _sessionId-C0CA68QV.js} +1 -1
- package/.output/public/assets/{index-Bq-nqolG.js → index-BpBUqdAB.js} +1 -1
- package/.output/public/assets/index-D93dCgAQ.js +1 -0
- package/.output/public/assets/{index-DPiESBo2.js → index-FV5sxqx3.js} +6 -6
- package/.output/public/assets/index-VUsF4nhf.js +76 -0
- package/.output/public/assets/index-hyQHZeQI.css +1 -0
- package/.output/public/assets/{json-viewer-CfaDJwx8.js → json-viewer-WctIbAh_.js} +1 -1
- package/.output/public/assets/jszip.min-DnSIoTix.js +2 -0
- package/.output/public/assets/qwen-mMn3f5ul.webp +0 -0
- package/.output/server/_libs/jszip.mjs +35 -9
- package/.output/server/_libs/lucide-react.mjs +80 -80
- package/.output/server/_libs/radix-ui__react-dialog.mjs +3 -3
- package/.output/server/_libs/tanstack__react-router.mjs +1 -1
- package/.output/server/{_sessionId-CwGXPD4C.mjs → _sessionId-BHBK3VbF.mjs} +15 -15
- package/.output/server/_sessionId-CyuJ6Kfl.mjs +81 -0
- package/.output/server/_ssr/{CompareDrawer-R_P1-VNr.mjs → CompareDrawer-27amlv_a.mjs} +138 -103
- package/.output/server/_ssr/InspectorPet-CQnGXfLs.mjs +925 -0
- package/.output/server/_ssr/{ProxyViewerContainer-DUgxRNbj.mjs → ProxyViewerContainer-DlAl0uF-.mjs} +1569 -1646
- package/.output/server/_ssr/{ReplayDialog-De9wKKzS.mjs → ReplayDialog-5m2jtlLa.mjs} +18 -18
- package/.output/server/_ssr/{RequestAnatomy-Db4MxUYo.mjs → RequestAnatomy--1WtQNY9.mjs} +58 -58
- package/.output/server/_ssr/{ResponseView-8rDXOiNB.mjs → ResponseView-CgbqS8sr.mjs} +60 -71
- package/.output/server/_ssr/{StreamingChunkSequence-N9teZHkA.mjs → StreamingChunkSequence-BwpnopWx.mjs} +16 -16
- package/.output/server/_ssr/{index-D4DvByuW.mjs → index-CNVi1TzT.mjs} +15 -15
- package/.output/server/_ssr/index-DMCjH_4V.mjs +81 -0
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{json-viewer-BjoHRbjS.mjs → json-viewer-DNGN2uoe.mjs} +58 -58
- package/.output/server/_ssr/{router-BYlePDj8.mjs → router-CgsqLnk5.mjs} +16007 -12632
- package/.output/server/_tanstack-start-manifest_v-BH5f_kWV.mjs +4 -0
- package/.output/server/index.mjs +99 -71
- package/.output/workers/logFinalizer.worker.js +16908 -0
- package/.output/workers/sessionWorkerEntry.js +16904 -0
- package/README.md +21 -5
- package/package.json +6 -2
- package/src/assets/logos/qwen.webp +0 -0
- package/src/cli.ts +85 -10
- package/src/components/OnboardingBanner.tsx +41 -21
- package/src/components/ProxyViewer.tsx +198 -269
- package/src/components/ProxyViewerContainer.tsx +128 -17
- package/src/components/ecosystem/AgentLabDialog.tsx +166 -19
- package/src/components/errors/SafeErrorBoundary.tsx +201 -0
- package/src/components/pi-agent/PiAgentPanel.tsx +209 -35
- package/src/components/pi-agent/piAgentChatLogic.ts +263 -0
- package/src/components/providers/ImportWizardDialog.tsx +18 -42
- package/src/components/providers/ProviderLogo.tsx +1 -1
- package/src/components/providers/ProvidersPanel.tsx +8 -5
- package/src/components/providers/SettingsDialog.tsx +34 -11
- package/src/components/proxy-viewer/ApplicationBar.tsx +63 -0
- package/src/components/proxy-viewer/CompareDrawer.tsx +92 -49
- package/src/components/proxy-viewer/LiveConnectionStatus.tsx +186 -0
- package/src/components/proxy-viewer/LogEntry.tsx +283 -275
- package/src/components/proxy-viewer/RawExportWarning.tsx +77 -0
- package/src/components/proxy-viewer/SessionLoadProgressBar.tsx +61 -0
- package/src/components/proxy-viewer/ThreadConnector.tsx +16 -1
- package/src/components/proxy-viewer/TurnGroup.tsx +13 -2
- package/src/components/proxy-viewer/accessibility.ts +8 -0
- package/src/components/proxy-viewer/lazy.ts +4 -0
- package/src/components/proxy-viewer/liveConnectionState.ts +158 -0
- package/src/components/proxy-viewer/proxyViewerLogic.ts +10 -0
- package/src/components/proxy-viewer/viewerState.ts +2 -6
- package/src/components/ui/select.tsx +1 -1
- package/src/components/ui/tabs.tsx +3 -3
- package/src/components/ui/transient-toast.tsx +7 -6
- package/src/lib/apiClient.ts +17 -2
- package/src/lib/ecosystemContract.ts +34 -0
- package/src/lib/export-logs.ts +1 -1
- package/src/lib/piAgentContract.ts +80 -0
- package/src/lib/providerContract.ts +70 -4
- package/src/lib/providerImportContract.ts +27 -0
- package/src/lib/providerModelMetadata.ts +16 -7
- package/src/lib/resourceLimits.ts +152 -0
- package/src/lib/safeDiagnostic.ts +38 -0
- package/src/lib/useProviders.ts +4 -4
- package/src/mcp/server.ts +39 -6
- package/src/mcp/toolHandlers.ts +131 -4
- package/src/proxy/chunkStorage.ts +20 -6
- package/src/proxy/config.ts +20 -6
- package/src/proxy/dataDir.ts +3 -0
- package/src/proxy/ecosystemExecutionConfirmation.ts +114 -0
- package/src/proxy/ecosystemExecutionRoute.ts +116 -0
- package/src/proxy/ecosystemTasks.ts +48 -0
- package/src/proxy/evidenceExporter.ts +23 -9
- package/src/proxy/groupEvidenceExporter.ts +26 -5
- package/src/proxy/groupStore.ts +13 -3
- package/src/proxy/handler.ts +103 -20
- package/src/proxy/identityProxy.ts +333 -14
- package/src/proxy/logFinalizer.ts +73 -6
- package/src/proxy/logImportUpload.ts +128 -0
- package/src/proxy/logImporter.ts +321 -70
- package/src/proxy/logIndex.ts +16 -6
- package/src/proxy/logger.ts +294 -41
- package/src/proxy/privateDataPath.ts +183 -0
- package/src/proxy/providerScanStore.ts +87 -0
- package/src/proxy/providerSecretStore.ts +58 -19
- package/src/proxy/providers.ts +175 -58
- package/src/proxy/rawStreamCapture.ts +66 -5
- package/src/proxy/runStore.ts +13 -3
- package/src/proxy/runtimeAdmission.ts +52 -0
- package/src/proxy/runtimeHealth.ts +206 -0
- package/src/proxy/runtimeShutdown.ts +75 -0
- package/src/proxy/sessionArchive.ts +15 -2
- package/src/proxy/sessionProcess.ts +19 -0
- package/src/proxy/sessionRuntime.ts +7 -0
- package/src/proxy/shutdownCoordinator.ts +90 -0
- package/src/proxy/sqliteLogIndex.ts +18 -2
- package/src/proxy/store.ts +18 -2
- package/src/routes/__root.tsx +18 -1
- package/src/routes/api/alerts.summary.ts +12 -8
- package/src/routes/api/alerts.ts +27 -9
- package/src/routes/api/ecosystem.packages.$packageId.help.ts +3 -8
- package/src/routes/api/ecosystem.packages.$packageId.install.ts +3 -8
- package/src/routes/api/ecosystem.packages.$packageId.runner-presets.ts +3 -11
- package/src/routes/api/ecosystem.packages.$packageId.upgrade.ts +3 -8
- package/src/routes/api/ecosystem.recipes.$recipeId.run.ts +3 -8
- package/src/routes/api/health.ts +9 -2
- package/src/routes/api/knowledge.project-context.ts +11 -0
- package/src/routes/api/knowledge.search.ts +33 -2
- package/src/routes/api/logs.$id.body.ts +16 -2
- package/src/routes/api/logs.import.ts +7 -18
- package/src/routes/api/logs.stream.ts +147 -69
- package/src/routes/api/logs.ts +55 -14
- package/src/routes/api/providers.$providerId.model-metadata.ts +22 -4
- package/src/routes/api/providers.$providerId.ts +37 -6
- package/src/routes/api/providers.export.ts +34 -16
- package/src/routes/api/providers.import.ts +42 -8
- package/src/routes/api/providers.scan.ts +13 -8
- package/src/routes/api/providers.ts +36 -5
- package/src/routes/api/runs.ts +12 -12
- package/src/routes/api/sessions.ts +15 -8
- package/src/routes/index.tsx +6 -0
- package/src/routes/livez.ts +13 -0
- package/src/routes/readyz.ts +18 -0
- package/src/routes/session/$sessionId.tsx +6 -0
- package/src/services/piAgent.ts +131 -102
- package/src/services/piAgentRetrieval.ts +1180 -0
- package/styles/globals.css +19 -3
- package/.output/public/assets/CompareDrawer-Cb5ccPeX.js +0 -1
- package/.output/public/assets/ProxyViewerContainer-COBe5GTT.js +0 -4234
- package/.output/public/assets/ResponseView-Cqq87lX_.js +0 -3
- package/.output/public/assets/StreamingChunkSequence-CnTa9mCr.js +0 -1
- package/.output/public/assets/index-DTLNVcgc.js +0 -76
- package/.output/public/assets/index-DliqmwUw.css +0 -1
- package/.output/public/assets/qwen-CONDcHqt.png +0 -0
- package/.output/server/_tanstack-start-manifest_v-DMCA8KK3.mjs +0 -4
- package/src/assets/logos/mcp.png +0 -0
- package/src/assets/logos/qwen.png +0 -0
- package/src/components/ui/mcp-logo.tsx +0 -20
package/src/proxy/logger.ts
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { appendFileSync } from "node:fs";
|
|
3
|
+
import { appendFile, readdir, rename, stat, unlink } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { getDataDir } from "./dataDir";
|
|
6
|
+
import {
|
|
7
|
+
ensurePrivateDirectorySync,
|
|
8
|
+
PRIVATE_FILE_MODE,
|
|
9
|
+
securePrivateFileSync,
|
|
10
|
+
securePrivateTreeSync,
|
|
11
|
+
} from "./privateDataPath";
|
|
5
12
|
|
|
6
|
-
const
|
|
13
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
const DEFAULT_RETENTION_DAYS = 7;
|
|
15
|
+
const DEFAULT_APP_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
16
|
+
const DEFAULT_APP_LOG_MAX_FILES = 5;
|
|
17
|
+
|
|
18
|
+
type ApplicationLogPolicy = {
|
|
19
|
+
maxBytes: number;
|
|
20
|
+
maxFiles: number;
|
|
21
|
+
retentionMs: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type ApplicationLogFile = {
|
|
25
|
+
generation: number;
|
|
26
|
+
modifiedAt: number;
|
|
27
|
+
path: string;
|
|
28
|
+
};
|
|
7
29
|
|
|
8
30
|
function writeStderrFallback(message: string, error: unknown): void {
|
|
9
31
|
process.stderr.write(`${message} ${String(error)}\n`);
|
|
@@ -33,54 +55,283 @@ function getInspectorLogPath(): string {
|
|
|
33
55
|
return path.join(getDataDir(), "logs", "inspector.log");
|
|
34
56
|
}
|
|
35
57
|
|
|
58
|
+
function readBoundedInteger(
|
|
59
|
+
name: string,
|
|
60
|
+
fallback: number,
|
|
61
|
+
minimum: number,
|
|
62
|
+
maximum: number,
|
|
63
|
+
): number {
|
|
64
|
+
const raw = process.env[name];
|
|
65
|
+
if (raw === undefined || raw === "") return fallback;
|
|
66
|
+
const parsed = Number(raw);
|
|
67
|
+
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) return fallback;
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getApplicationLogPolicy(): ApplicationLogPolicy {
|
|
72
|
+
const retentionDays = readBoundedInteger("LOG_RETENTION_DAYS", DEFAULT_RETENTION_DAYS, 1, 3650);
|
|
73
|
+
return {
|
|
74
|
+
maxBytes: readBoundedInteger(
|
|
75
|
+
"AGENT_INSPECTOR_APP_LOG_MAX_BYTES",
|
|
76
|
+
DEFAULT_APP_LOG_MAX_BYTES,
|
|
77
|
+
1,
|
|
78
|
+
1024 * 1024 * 1024,
|
|
79
|
+
),
|
|
80
|
+
maxFiles: readBoundedInteger(
|
|
81
|
+
"AGENT_INSPECTOR_APP_LOG_MAX_FILES",
|
|
82
|
+
DEFAULT_APP_LOG_MAX_FILES,
|
|
83
|
+
1,
|
|
84
|
+
100,
|
|
85
|
+
),
|
|
86
|
+
retentionMs: retentionDays * DAY_MS,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getRotatedGeneration(entry: string, baseName: string): number | null {
|
|
91
|
+
const prefix = `${baseName}.`;
|
|
92
|
+
if (!entry.startsWith(prefix)) return null;
|
|
93
|
+
const suffix = entry.slice(prefix.length);
|
|
94
|
+
if (!/^\d+$/.test(suffix)) return null;
|
|
95
|
+
const generation = Number(suffix);
|
|
96
|
+
if (!Number.isSafeInteger(generation) || generation < 1) return null;
|
|
97
|
+
return generation;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function listApplicationLogFiles(logPath: string): Promise<ApplicationLogFile[]> {
|
|
101
|
+
const directory = path.dirname(logPath);
|
|
102
|
+
const baseName = path.basename(logPath);
|
|
103
|
+
const entries = await readdir(directory);
|
|
104
|
+
const files: ApplicationLogFile[] = [];
|
|
105
|
+
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const generation = entry === baseName ? 0 : getRotatedGeneration(entry, baseName);
|
|
108
|
+
if (generation === null) continue;
|
|
109
|
+
const filePath = path.join(directory, entry);
|
|
110
|
+
try {
|
|
111
|
+
const fileStat = await stat(filePath);
|
|
112
|
+
if (!fileStat.isFile()) continue;
|
|
113
|
+
files.push({ generation, modifiedAt: fileStat.mtimeMs, path: filePath });
|
|
114
|
+
} catch {
|
|
115
|
+
// The file may have been removed by another maintenance pass.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return files;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function removeApplicationLog(filePath: string): Promise<boolean> {
|
|
123
|
+
try {
|
|
124
|
+
await stat(filePath);
|
|
125
|
+
} catch {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await unlink(filePath);
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
// Retention is best-effort; a later maintenance pass retries failed removals.
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function maintainApplicationLogs(
|
|
138
|
+
logPath: string,
|
|
139
|
+
policy: ApplicationLogPolicy,
|
|
140
|
+
): Promise<boolean> {
|
|
141
|
+
const directory = path.dirname(logPath);
|
|
142
|
+
if (!ensurePrivateDirectorySync(directory)) return false;
|
|
143
|
+
|
|
144
|
+
let files: ApplicationLogFile[];
|
|
145
|
+
try {
|
|
146
|
+
files = await listApplicationLogFiles(logPath);
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const file of files) {
|
|
152
|
+
if (!securePrivateFileSync(file.path)) return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const cutoff = Date.now() - policy.retentionMs;
|
|
156
|
+
const retained = files.filter((file) => file.modifiedAt >= cutoff);
|
|
157
|
+
let maintained = true;
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
if (file.modifiedAt < cutoff && !(await removeApplicationLog(file.path))) maintained = false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rotated = retained
|
|
163
|
+
.filter((file) => file.generation > 0)
|
|
164
|
+
.sort((left, right) => right.modifiedAt - left.modifiedAt);
|
|
165
|
+
const retainedRotatedCount = Math.max(0, policy.maxFiles - 1);
|
|
166
|
+
for (const file of rotated.slice(retainedRotatedCount)) {
|
|
167
|
+
if (!(await removeApplicationLog(file.path))) maintained = false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return maintained;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function moveApplicationLog(source: string, destination: string): Promise<boolean> {
|
|
174
|
+
try {
|
|
175
|
+
await stat(source);
|
|
176
|
+
} catch {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await rename(source, destination);
|
|
181
|
+
if (!securePrivateFileSync(destination)) {
|
|
182
|
+
writeStderrFallback("[logger] Failed to secure rotated application log:", destination);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
writeStderrFallback("[logger] Failed to rotate application log:", error);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function rotateApplicationLog(logPath: string, maxFiles: number): Promise<boolean> {
|
|
193
|
+
if (maxFiles === 1) {
|
|
194
|
+
return await removeApplicationLog(logPath);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!(await removeApplicationLog(`${logPath}.${String(maxFiles - 1)}`))) return false;
|
|
198
|
+
for (let generation = maxFiles - 2; generation >= 1; generation -= 1) {
|
|
199
|
+
if (
|
|
200
|
+
!(await moveApplicationLog(
|
|
201
|
+
`${logPath}.${String(generation)}`,
|
|
202
|
+
`${logPath}.${String(generation + 1)}`,
|
|
203
|
+
))
|
|
204
|
+
) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return await moveApplicationLog(logPath, `${logPath}.1`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function rotateApplicationLogIfNeeded(
|
|
212
|
+
logPath: string,
|
|
213
|
+
incomingBytes: number,
|
|
214
|
+
policy: ApplicationLogPolicy,
|
|
215
|
+
): Promise<boolean> {
|
|
216
|
+
try {
|
|
217
|
+
const fileStat = await stat(logPath);
|
|
218
|
+
if (fileStat.size > 0 && fileStat.size + incomingBytes > policy.maxBytes) {
|
|
219
|
+
return await rotateApplicationLog(logPath, policy.maxFiles);
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
} catch {
|
|
223
|
+
// The active file does not exist yet.
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
36
228
|
export async function initLogger(): Promise<void> {
|
|
37
229
|
const dir = resolveLogDir();
|
|
38
|
-
const
|
|
39
|
-
const cutoff = Date.now() - retentionMs;
|
|
230
|
+
const policy = getApplicationLogPolicy();
|
|
231
|
+
const cutoff = Date.now() - policy.retentionMs;
|
|
40
232
|
|
|
41
233
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
234
|
+
if (!ensurePrivateDirectorySync(dir)) {
|
|
235
|
+
writeStderrFallback("[logger] Failed to secure log directory:", dir);
|
|
236
|
+
} else if (!securePrivateTreeSync(dir)) {
|
|
237
|
+
writeStderrFallback("[logger] Failed to secure existing log files:", dir);
|
|
238
|
+
} else {
|
|
239
|
+
const entries = await readdir(dir);
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
if (!entry.endsWith(".jsonl")) continue;
|
|
242
|
+
const fullPath = path.join(dir, entry);
|
|
243
|
+
try {
|
|
244
|
+
const s = await stat(fullPath);
|
|
245
|
+
if (s.mtimeMs < cutoff) {
|
|
246
|
+
await unlink(fullPath);
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
// Skip files that can't be stat - not critical
|
|
51
250
|
}
|
|
52
|
-
} catch {
|
|
53
|
-
// Skip files that can't be stat - not critical
|
|
54
251
|
}
|
|
55
252
|
}
|
|
56
253
|
} catch (err) {
|
|
57
254
|
// Log directory initialization errors but don't fail startup
|
|
58
255
|
writeStderrFallback("[logger] Failed to initialize log directory:", err);
|
|
59
256
|
}
|
|
257
|
+
|
|
258
|
+
const applicationLogPath = getInspectorLogPath();
|
|
259
|
+
if (!(await maintainApplicationLogs(applicationLogPath, policy))) {
|
|
260
|
+
writeStderrFallback(
|
|
261
|
+
"[logger] Failed to initialize application log lifecycle:",
|
|
262
|
+
applicationLogPath,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
60
265
|
}
|
|
61
266
|
|
|
62
267
|
// File-based logger for application logs (not log entries)
|
|
63
|
-
let
|
|
268
|
+
let loggerInitialization: Promise<void> | null = null;
|
|
269
|
+
let applicationLogWriteQueue: Promise<void> = Promise.resolve();
|
|
64
270
|
|
|
65
|
-
|
|
66
|
-
// Auto-initialize on first use if not already initialized
|
|
67
|
-
if (!loggerInitialized) {
|
|
68
|
-
loggerInitialized = true;
|
|
69
|
-
void initLogger();
|
|
70
|
-
}
|
|
271
|
+
type ApplicationLogWriteResult = { ok: true } | { ok: false; reason: unknown };
|
|
71
272
|
|
|
72
|
-
|
|
273
|
+
function ensureLoggerInitialized(): Promise<void> {
|
|
274
|
+
if (loggerInitialization === null) loggerInitialization = initLogger();
|
|
275
|
+
return loggerInitialization;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function appendApplicationLogLine(
|
|
279
|
+
logPath: string,
|
|
280
|
+
line: string,
|
|
281
|
+
policy: ApplicationLogPolicy,
|
|
282
|
+
): Promise<ApplicationLogWriteResult> {
|
|
283
|
+
const logDirPath = path.dirname(logPath);
|
|
284
|
+
if (!ensurePrivateDirectorySync(logDirPath)) {
|
|
285
|
+
return { ok: false, reason: "application log directory is not private" };
|
|
286
|
+
}
|
|
73
287
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
await appendFile(logPath,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
288
|
+
if (!(await rotateApplicationLogIfNeeded(logPath, Buffer.byteLength(line, "utf-8"), policy))) {
|
|
289
|
+
return { ok: false, reason: "application log rotation failed" };
|
|
290
|
+
}
|
|
291
|
+
await appendFile(logPath, line, { encoding: "utf-8", mode: PRIVATE_FILE_MODE });
|
|
292
|
+
if (!securePrivateFileSync(logPath)) {
|
|
293
|
+
return { ok: false, reason: "application log permissions could not be secured" };
|
|
294
|
+
}
|
|
295
|
+
return { ok: true };
|
|
296
|
+
} catch (error) {
|
|
297
|
+
return { ok: false, reason: error };
|
|
81
298
|
}
|
|
82
299
|
}
|
|
83
300
|
|
|
301
|
+
/** Isolated lifecycle hook used by the rotation tests without mutating process-wide environment. */
|
|
302
|
+
export async function appendApplicationLogLineForTests(
|
|
303
|
+
logPath: string,
|
|
304
|
+
line: string,
|
|
305
|
+
maxBytes: number,
|
|
306
|
+
maxFiles: number,
|
|
307
|
+
): Promise<boolean> {
|
|
308
|
+
const result = await appendApplicationLogLine(logPath, line, {
|
|
309
|
+
maxBytes,
|
|
310
|
+
maxFiles,
|
|
311
|
+
retentionMs: DEFAULT_RETENTION_DAYS * DAY_MS,
|
|
312
|
+
});
|
|
313
|
+
return result.ok;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function writeAppLog(message: string): Promise<void> {
|
|
317
|
+
const logPath = getInspectorLogPath();
|
|
318
|
+
const line = `[${new Date().toISOString()}] ${message}\n`;
|
|
319
|
+
const initialization = ensureLoggerInitialized();
|
|
320
|
+
const write = async (): Promise<void> => {
|
|
321
|
+
await initialization;
|
|
322
|
+
const result = await appendApplicationLogLine(logPath, line, getApplicationLogPolicy());
|
|
323
|
+
if (!result.ok) writeStderrFallback(`[logger] Failed to write to ${logPath}:`, result.reason);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
applicationLogWriteQueue = applicationLogWriteQueue.then(write, write);
|
|
327
|
+
await applicationLogWriteQueue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Wait for all queued application-log writes and rotations. */
|
|
331
|
+
export async function flushApplicationLog(): Promise<void> {
|
|
332
|
+
await applicationLogWriteQueue;
|
|
333
|
+
}
|
|
334
|
+
|
|
84
335
|
// Application logger - writes to inspector.log
|
|
85
336
|
export const logger = {
|
|
86
337
|
debug(message: string, ...args: unknown[]): void {
|
|
@@ -137,8 +388,15 @@ async function flushWriteBufferUnlocked(): Promise<void> {
|
|
|
137
388
|
|
|
138
389
|
try {
|
|
139
390
|
const filePath = getLogFilePath();
|
|
140
|
-
|
|
141
|
-
|
|
391
|
+
if (!ensurePrivateDirectorySync(path.dirname(filePath))) {
|
|
392
|
+
writeBuffer.unshift(toWrite);
|
|
393
|
+
writeStderrFallback("[logger] Failed to secure captured-log directory:", filePath);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
await appendFile(filePath, toWrite, { encoding: "utf-8", mode: PRIVATE_FILE_MODE });
|
|
397
|
+
if (!securePrivateFileSync(filePath)) {
|
|
398
|
+
writeStderrFallback("[logger] Failed to secure captured-log file:", filePath);
|
|
399
|
+
}
|
|
142
400
|
} catch (err) {
|
|
143
401
|
// On failure, re-queue the data so it is not lost
|
|
144
402
|
writeBuffer.unshift(toWrite);
|
|
@@ -182,16 +440,11 @@ process.on("exit", () => {
|
|
|
182
440
|
try {
|
|
183
441
|
// Synchronous write required in 'exit' handler (async I/O not allowed)
|
|
184
442
|
const filePath = getLogFilePath();
|
|
185
|
-
|
|
186
|
-
|
|
443
|
+
if (!ensurePrivateDirectorySync(path.dirname(filePath))) return;
|
|
444
|
+
appendFileSync(filePath, toWrite, { encoding: "utf-8", mode: PRIVATE_FILE_MODE });
|
|
445
|
+
void securePrivateFileSync(filePath);
|
|
187
446
|
} catch {
|
|
188
447
|
// Best-effort: nothing we can do in an exit handler
|
|
189
448
|
}
|
|
190
449
|
}
|
|
191
450
|
});
|
|
192
|
-
|
|
193
|
-
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
194
|
-
process.on(signal, () => {
|
|
195
|
-
void flushLogBuffer().then(() => process.exit(0));
|
|
196
|
-
});
|
|
197
|
-
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
6
|
+
export const PRIVATE_FILE_MODE = 0o600;
|
|
7
|
+
|
|
8
|
+
export type PrivateDataPathKind = "directory" | "file";
|
|
9
|
+
|
|
10
|
+
type WindowsCommandResult = {
|
|
11
|
+
status: number | null;
|
|
12
|
+
stdout: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type WindowsCommandRunner = (
|
|
16
|
+
executable: string,
|
|
17
|
+
args: readonly string[],
|
|
18
|
+
) => WindowsCommandResult;
|
|
19
|
+
|
|
20
|
+
const securedPaths = new Set<string>();
|
|
21
|
+
const securedFileIdentities = new Map<string, string>();
|
|
22
|
+
let cachedWindowsSid: string | null = null;
|
|
23
|
+
|
|
24
|
+
function runWindowsCommand(executable: string, args: readonly string[]): WindowsCommandResult {
|
|
25
|
+
const result = spawnSync(executable, [...args], {
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
shell: false,
|
|
28
|
+
windowsHide: true,
|
|
29
|
+
});
|
|
30
|
+
return { status: result.status, stdout: result.stdout };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function windowsUserSid(output: string): string | null {
|
|
34
|
+
const match = /S-\d+(?:-\d+)+/.exec(output);
|
|
35
|
+
return match?.[0] ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveWindowsUserSid(run: WindowsCommandRunner): string | null {
|
|
39
|
+
if (run === runWindowsCommand && cachedWindowsSid !== null) return cachedWindowsSid;
|
|
40
|
+
const identity = run("whoami.exe", ["/user", "/fo", "csv", "/nh"]);
|
|
41
|
+
if (identity.status !== 0) return null;
|
|
42
|
+
const sid = windowsUserSid(identity.stdout);
|
|
43
|
+
if (run === runWindowsCommand) cachedWindowsSid = sid;
|
|
44
|
+
return sid;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function windowsBroadGroupRemovalArgs(): readonly string[] {
|
|
48
|
+
return ["/remove:g", "*S-1-1-0", "*S-1-5-11", "*S-1-5-32-545"];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Remove inherited Windows permissions and grant full control only to the
|
|
53
|
+
* account that owns the current process. Commands are executed directly,
|
|
54
|
+
* never through a shell, and no path content is interpreted as command text.
|
|
55
|
+
*/
|
|
56
|
+
export function applyWindowsUserOnlyAcl(
|
|
57
|
+
path: string,
|
|
58
|
+
kind: PrivateDataPathKind,
|
|
59
|
+
run: WindowsCommandRunner = runWindowsCommand,
|
|
60
|
+
): boolean {
|
|
61
|
+
const sid = resolveWindowsUserSid(run);
|
|
62
|
+
if (sid === null) return false;
|
|
63
|
+
|
|
64
|
+
const access = kind === "directory" ? `(OI)(CI)F` : "F";
|
|
65
|
+
const result = run("icacls.exe", [
|
|
66
|
+
path,
|
|
67
|
+
"/inheritance:r",
|
|
68
|
+
"/grant:r",
|
|
69
|
+
`*${sid}:${access}`,
|
|
70
|
+
...windowsBroadGroupRemovalArgs(),
|
|
71
|
+
"/q",
|
|
72
|
+
]);
|
|
73
|
+
return result.status === 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Apply a user-only ACL to an existing Windows tree in one bounded process. */
|
|
77
|
+
export function applyWindowsUserOnlyTreeAcl(
|
|
78
|
+
root: string,
|
|
79
|
+
run: WindowsCommandRunner = runWindowsCommand,
|
|
80
|
+
): boolean {
|
|
81
|
+
const sid = resolveWindowsUserSid(run);
|
|
82
|
+
if (sid === null) return false;
|
|
83
|
+
const result = run("icacls.exe", [
|
|
84
|
+
root,
|
|
85
|
+
"/inheritance:r",
|
|
86
|
+
"/grant:r",
|
|
87
|
+
`*${sid}:(OI)(CI)F`,
|
|
88
|
+
`*${sid}:F`,
|
|
89
|
+
...windowsBroadGroupRemovalArgs(),
|
|
90
|
+
"/T",
|
|
91
|
+
"/C",
|
|
92
|
+
"/Q",
|
|
93
|
+
]);
|
|
94
|
+
return result.status === 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function applyPrivatePermissionsSync(path: string, kind: PrivateDataPathKind): boolean {
|
|
98
|
+
if (process.platform === "win32") {
|
|
99
|
+
return applyWindowsUserOnlyAcl(path, kind);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
chmodSync(path, kind === "directory" ? PRIVATE_DIRECTORY_MODE : PRIVATE_FILE_MODE);
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function secureOnce(path: string, kind: PrivateDataPathKind): boolean {
|
|
111
|
+
const cacheKey = `${kind}:${path}`;
|
|
112
|
+
if (securedPaths.has(cacheKey)) return true;
|
|
113
|
+
if (!applyPrivatePermissionsSync(path, kind)) return false;
|
|
114
|
+
securedPaths.add(cacheKey);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function ensurePrivateDirectorySync(path: string): boolean {
|
|
119
|
+
try {
|
|
120
|
+
mkdirSync(path, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return secureOnce(path, "directory");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function securePrivateFileSync(path: string, forceAcl = false): boolean {
|
|
128
|
+
let identity: string;
|
|
129
|
+
try {
|
|
130
|
+
const stats = statSync(path);
|
|
131
|
+
identity = `${String(stats.dev)}:${String(stats.ino)}:${String(stats.birthtimeMs)}`;
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
if (securedFileIdentities.get(path) === identity) return true;
|
|
136
|
+
if (process.platform === "win32" && !forceAcl && securedPaths.has(`directory:${dirname(path)}`)) {
|
|
137
|
+
securedFileIdentities.set(path, identity);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (!applyPrivatePermissionsSync(path, "file")) return false;
|
|
141
|
+
securedFileIdentities.set(path, identity);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Secure an existing tree without following symbolic links. */
|
|
146
|
+
export function securePrivateTreeSync(root: string): boolean {
|
|
147
|
+
if (!existsSync(root)) return false;
|
|
148
|
+
|
|
149
|
+
let stats: ReturnType<typeof lstatSync>;
|
|
150
|
+
try {
|
|
151
|
+
stats = lstatSync(root);
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (stats.isSymbolicLink()) return true;
|
|
156
|
+
if (!stats.isDirectory()) return securePrivateFileSync(root, true);
|
|
157
|
+
if (process.platform === "win32") {
|
|
158
|
+
if (!applyWindowsUserOnlyTreeAcl(root)) return false;
|
|
159
|
+
securedPaths.add(`directory:${root}`);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
if (!ensurePrivateDirectorySync(root)) return false;
|
|
163
|
+
|
|
164
|
+
let entries: string[];
|
|
165
|
+
try {
|
|
166
|
+
entries = readdirSync(root);
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let secured = true;
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
const childPath = join(root, entry);
|
|
174
|
+
if (!securePrivateTreeSync(childPath)) secured = false;
|
|
175
|
+
}
|
|
176
|
+
return secured;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function _resetPrivateDataPathCacheForTests(): void {
|
|
180
|
+
securedPaths.clear();
|
|
181
|
+
securedFileIdentities.clear();
|
|
182
|
+
cachedWindowsSid = null;
|
|
183
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { maskApiKey } from "../lib/mask";
|
|
3
|
+
import type { ScannedProviderDto } from "../lib/providerImportContract";
|
|
4
|
+
import type { ExternalProvider } from "./providerImporters";
|
|
5
|
+
|
|
6
|
+
const SCAN_TTL_MS = 5 * 60 * 1000;
|
|
7
|
+
const MAX_ACTIVE_SCANS = 8;
|
|
8
|
+
const MAX_SCANNED_PROVIDERS = 100;
|
|
9
|
+
|
|
10
|
+
type ProviderScanSession = {
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
providers: ReadonlyMap<string, ExternalProvider>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const sessions = new Map<string, ProviderScanSession>();
|
|
16
|
+
|
|
17
|
+
function removeExpired(now: number): void {
|
|
18
|
+
for (const [scanId, session] of sessions) {
|
|
19
|
+
if (session.expiresAt <= now) sessions.delete(scanId);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function enforceSessionLimit(): void {
|
|
24
|
+
while (sessions.size >= MAX_ACTIVE_SCANS) {
|
|
25
|
+
const oldest = sessions.keys().next().value;
|
|
26
|
+
if (typeof oldest !== "string") return;
|
|
27
|
+
sessions.delete(oldest);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createProviderScan(
|
|
32
|
+
providers: readonly ExternalProvider[],
|
|
33
|
+
now = Date.now(),
|
|
34
|
+
): { scanId: string; providers: ScannedProviderDto[] } {
|
|
35
|
+
removeExpired(now);
|
|
36
|
+
enforceSessionLimit();
|
|
37
|
+
|
|
38
|
+
const scanId = randomUUID();
|
|
39
|
+
const providerMap = new Map<string, ExternalProvider>();
|
|
40
|
+
const redacted: ScannedProviderDto[] = [];
|
|
41
|
+
for (const provider of providers.slice(0, MAX_SCANNED_PROVIDERS)) {
|
|
42
|
+
const selectionId = randomUUID();
|
|
43
|
+
providerMap.set(selectionId, provider);
|
|
44
|
+
redacted.push({
|
|
45
|
+
selectionId,
|
|
46
|
+
name: provider.name,
|
|
47
|
+
apiKeyHint: maskApiKey(provider.apiKey),
|
|
48
|
+
format: provider.format,
|
|
49
|
+
anthropicBaseUrl: provider.anthropicBaseUrl,
|
|
50
|
+
openaiBaseUrl: provider.openaiBaseUrl,
|
|
51
|
+
openaiResponsesBaseUrl: provider.openaiResponsesBaseUrl,
|
|
52
|
+
models: provider.models,
|
|
53
|
+
sourceTool: provider.sourceTool,
|
|
54
|
+
alreadyExists: provider.alreadyExists,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
sessions.set(scanId, {
|
|
59
|
+
expiresAt: now + SCAN_TTL_MS,
|
|
60
|
+
providers: providerMap,
|
|
61
|
+
});
|
|
62
|
+
return { scanId, providers: redacted };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function consumeProviderScan(
|
|
66
|
+
scanId: string,
|
|
67
|
+
selectionIds: readonly string[],
|
|
68
|
+
now = Date.now(),
|
|
69
|
+
): ExternalProvider[] | null {
|
|
70
|
+
removeExpired(now);
|
|
71
|
+
const session = sessions.get(scanId);
|
|
72
|
+
if (session === undefined) return null;
|
|
73
|
+
sessions.delete(scanId);
|
|
74
|
+
|
|
75
|
+
const uniqueIds = new Set(selectionIds);
|
|
76
|
+
const selected: ExternalProvider[] = [];
|
|
77
|
+
for (const selectionId of uniqueIds) {
|
|
78
|
+
const provider = session.providers.get(selectionId);
|
|
79
|
+
if (provider === undefined) return null;
|
|
80
|
+
selected.push(provider);
|
|
81
|
+
}
|
|
82
|
+
return selected;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function _resetProviderScanStoreForTests(): void {
|
|
86
|
+
sessions.clear();
|
|
87
|
+
}
|