@senad-d/observme 0.1.3 → 0.1.5
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/.env.example +4 -0
- package/CHANGELOG.md +48 -0
- package/README.md +36 -11
- package/dashboards/observme-agent-node-graphs.json +5 -5
- package/dashboards/observme-agents.json +169 -4
- package/dashboards/observme-alerts.yaml +16 -3
- package/dashboards/observme-overview.json +6 -6
- package/dashboards/observme-trace-journey.json +4 -4
- package/docs/agent-subagent-observability-requirements.md +19 -10
- package/docs/compatibility-matrix.md +21 -4
- package/docs/configuration.md +7 -2
- package/docs/extension-integration.md +20 -13
- package/docs/reference/03-pi-event-and-session-model.md +6 -6
- package/docs/reference/04-telemetry-semantic-conventions.md +39 -1
- package/docs/reference/05-otel-pipeline-and-collector.md +23 -10
- package/docs/reference/06-security-privacy-redaction.md +13 -6
- package/docs/reference/08-query-grafana-integration.md +30 -7
- package/docs/reference/09-dashboards-alerts-slos.md +132 -3
- package/docs/reference/10-testing-release-operations.md +44 -14
- package/docs/reference/11-deployment-runbooks.md +82 -5
- package/docs/reference/12-configuration-reference.md +48 -5
- package/docs/review-validation.md +17 -0
- package/examples/README.md +7 -2
- package/examples/collector.yaml +8 -8
- package/examples/integrations/subagent-runner.ts +8 -5
- package/examples/observme.yaml +3 -2
- package/package.json +14 -4
- package/src/commands/obs-agents.ts +17 -12
- package/src/commands/obs-backfill.ts +2 -2
- package/src/commands/obs-command-support.ts +2 -1
- package/src/commands/obs-cost.ts +15 -7
- package/src/commands/obs-health.ts +22 -2
- package/src/commands/obs-loki-summary.ts +4 -8
- package/src/commands/obs-session.ts +35 -28
- package/src/commands/obs-status.ts +56 -13
- package/src/commands/obs-tools.ts +19 -8
- package/src/commands/obs-trace.ts +9 -0
- package/src/commands/obs.ts +6 -1
- package/src/config/bootstrap-project-config.ts +50 -32
- package/src/config/defaults.ts +1 -2
- package/src/config/load-config.ts +270 -81
- package/src/config/project-paths.ts +318 -6
- package/src/config/query-limits.ts +10 -0
- package/src/config/schema.ts +18 -8
- package/src/config/transport-security.ts +107 -0
- package/src/config/validate.ts +88 -12
- package/src/extension.ts +2 -12
- package/src/integration.ts +6 -2
- package/src/otel/logs.ts +24 -13
- package/src/otel/metrics.ts +24 -13
- package/src/otel/otlp-endpoint.ts +41 -2
- package/src/otel/otlp-http-options.ts +11 -0
- package/src/otel/sdk.ts +155 -9
- package/src/otel/shutdown.ts +39 -11
- package/src/otel/traces.ts +34 -16
- package/src/pi/active-agent-lease.ts +101 -0
- package/src/pi/agent-tree-tracker.ts +14 -1
- package/src/pi/compatibility.ts +121 -0
- package/src/pi/event-handlers/agent-turn.ts +16 -9
- package/src/pi/event-handlers/lifecycle.ts +312 -106
- package/src/pi/event-handlers/llm.ts +17 -9
- package/src/pi/event-handlers/session-events.ts +53 -19
- package/src/pi/event-handlers/tool-bash.ts +21 -27
- package/src/pi/handler-internals.ts +16 -26
- package/src/pi/handler-runtime.ts +159 -54
- package/src/pi/handler-types.ts +40 -17
- package/src/pi/handlers.ts +12 -1
- package/src/pi/integration-api.ts +27 -15
- package/src/pi/session-correlation.ts +167 -0
- package/src/pi/subagent-spawn.ts +164 -31
- package/src/privacy/redact.ts +574 -68
- package/src/privacy/secret-patterns.ts +6 -1
- package/src/query/grafana-readiness.ts +18 -2
- package/src/query/grafana-transport.ts +150 -27
- package/src/query/grafana-url.ts +36 -0
- package/src/query/grafana.ts +4 -136
- package/src/query/loki.ts +2 -4
- package/src/query/prometheus.ts +8 -10
- package/src/query/tempo.ts +2 -4
- package/src/query/trace-link.ts +186 -0
- package/src/safety/display-bounds.ts +84 -0
- package/src/semconv/metrics.ts +7 -0
- package/src/semconv/values.ts +2 -0
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import type { BigIntStats } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, realpath, stat, unlink } from "node:fs/promises";
|
|
4
|
+
import type { FileHandle } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
2
6
|
|
|
3
7
|
export interface ProjectLocalFilePathOptions {
|
|
4
8
|
readonly cwd?: string;
|
|
@@ -9,17 +13,107 @@ export interface ProjectLocalFilePathOptions {
|
|
|
9
13
|
readonly inputLabel: string;
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
export interface ProjectLocalFileOperationHooks {
|
|
17
|
+
readonly beforeOpen?: () => Promise<void> | void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ExclusiveProjectLocalFileCreateStatus = "created" | "exists";
|
|
21
|
+
|
|
22
|
+
interface FileIdentity {
|
|
23
|
+
readonly device: bigint;
|
|
24
|
+
readonly inode: bigint;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CanonicalProjectPathSnapshot {
|
|
28
|
+
readonly rootPath: string;
|
|
29
|
+
readonly canonicalRootPath: string;
|
|
30
|
+
readonly canonicalCandidatePath: string;
|
|
31
|
+
readonly rootIdentity: FileIdentity;
|
|
32
|
+
readonly inputLabel: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
12
35
|
const parentSegment = "..";
|
|
36
|
+
const unsafeProjectPathErrorCode = "OBSERVME_UNSAFE_PROJECT_PATH";
|
|
13
37
|
|
|
14
38
|
export function resolveProjectLocalFilePath(options: ProjectLocalFilePathOptions): string {
|
|
15
39
|
const root = resolve(options.cwd ?? process.cwd());
|
|
16
40
|
const candidatePath = resolveProjectLocalCandidatePath(root, options);
|
|
17
41
|
|
|
18
|
-
|
|
42
|
+
assertProjectContainment(root, candidatePath, options.inputLabel);
|
|
19
43
|
if (shouldPreserveRelativeOverride(options)) return options.overridePath!;
|
|
20
44
|
return candidatePath;
|
|
21
45
|
}
|
|
22
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Project-local I/O policy: lexical paths and their canonical targets must remain
|
|
49
|
+
* inside one stable canonical root. Safe in-root symlinks are pinned to their
|
|
50
|
+
* canonical target, missing final paths are supported, and an opened file's
|
|
51
|
+
* device/inode identity is verified before bytes are read or written.
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveCanonicalProjectLocalFilePath(
|
|
54
|
+
options: ProjectLocalFilePathOptions,
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
return (await createCanonicalProjectPathSnapshot(options)).canonicalCandidatePath;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function readCanonicalProjectLocalFileText(
|
|
60
|
+
options: ProjectLocalFilePathOptions,
|
|
61
|
+
hooks: ProjectLocalFileOperationHooks = {},
|
|
62
|
+
): Promise<string | undefined> {
|
|
63
|
+
const snapshot = await createCanonicalProjectPathSnapshot(options);
|
|
64
|
+
await hooks.beforeOpen?.();
|
|
65
|
+
|
|
66
|
+
let fileHandle: FileHandle | undefined;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
fileHandle = await openProjectFileForRead(snapshot);
|
|
70
|
+
if (!fileHandle) return undefined;
|
|
71
|
+
await assertOpenedProjectFileIsStable(snapshot, fileHandle);
|
|
72
|
+
return await fileHandle.readFile({ encoding: "utf8" });
|
|
73
|
+
} finally {
|
|
74
|
+
await closeFileHandle(fileHandle);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function createCanonicalProjectLocalFileExclusively(
|
|
79
|
+
options: ProjectLocalFilePathOptions,
|
|
80
|
+
content: string,
|
|
81
|
+
hooks: ProjectLocalFileOperationHooks = {},
|
|
82
|
+
): Promise<ExclusiveProjectLocalFileCreateStatus> {
|
|
83
|
+
const initialSnapshot = await createCanonicalProjectPathSnapshot(options);
|
|
84
|
+
await mkdir(dirname(initialSnapshot.canonicalCandidatePath), { recursive: true });
|
|
85
|
+
const snapshot = await createCanonicalProjectPathSnapshot(options);
|
|
86
|
+
await hooks.beforeOpen?.();
|
|
87
|
+
|
|
88
|
+
let fileHandle: FileHandle | undefined;
|
|
89
|
+
let openedIdentity: FileIdentity | undefined;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
fileHandle = await openProjectFileForExclusiveCreate(snapshot);
|
|
93
|
+
if (!fileHandle) {
|
|
94
|
+
await assertExistingProjectFileIsSafe(options);
|
|
95
|
+
return "exists";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
openedIdentity = toFileIdentity(await fileHandle.stat({ bigint: true }));
|
|
99
|
+
await assertOpenedProjectFileIsStable(snapshot, fileHandle);
|
|
100
|
+
await fileHandle.writeFile(content, { encoding: "utf8" });
|
|
101
|
+
await assertOpenedProjectFileIsStable(snapshot, fileHandle);
|
|
102
|
+
return "created";
|
|
103
|
+
} catch (error) {
|
|
104
|
+
await closeFileHandle(fileHandle);
|
|
105
|
+
fileHandle = undefined;
|
|
106
|
+
if (openedIdentity) await removeCreatedFileIfIdentityMatches(snapshot, openedIdentity);
|
|
107
|
+
throw error;
|
|
108
|
+
} finally {
|
|
109
|
+
await closeFileHandle(fileHandle);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isUnsafeProjectPathError(error: unknown): boolean {
|
|
114
|
+
return isErrorWithCode(error) && error.code === unsafeProjectPathErrorCode;
|
|
115
|
+
}
|
|
116
|
+
|
|
23
117
|
function resolveProjectLocalCandidatePath(root: string, options: ProjectLocalFilePathOptions): string {
|
|
24
118
|
if (options.overridePath !== undefined) return resolveOverridePath(root, options.overridePath, options.inputLabel);
|
|
25
119
|
return resolveConfigDirPath(root, options);
|
|
@@ -30,16 +124,16 @@ function shouldPreserveRelativeOverride(options: ProjectLocalFilePathOptions): b
|
|
|
30
124
|
}
|
|
31
125
|
|
|
32
126
|
function resolveOverridePath(root: string, overridePath: string, inputLabel: string): string {
|
|
33
|
-
if (isRelativeTraversalPath(overridePath)) throw
|
|
127
|
+
if (isRelativeTraversalPath(overridePath)) throw createUnsafeProjectPathError(inputLabel);
|
|
34
128
|
return isAbsolute(overridePath) ? resolve(overridePath) : resolve(root, overridePath);
|
|
35
129
|
}
|
|
36
130
|
|
|
37
131
|
function resolveConfigDirPath(root: string, options: ProjectLocalFilePathOptions): string {
|
|
38
132
|
const configDirName = options.configDirName ?? options.defaultConfigDirName;
|
|
39
133
|
|
|
40
|
-
if (!configDirName) throw
|
|
41
|
-
if (isAbsolute(configDirName)) throw
|
|
42
|
-
if (hasParentSegment(configDirName)) throw
|
|
134
|
+
if (!configDirName) throw createUnsafeProjectPathError(options.inputLabel);
|
|
135
|
+
if (isAbsolute(configDirName)) throw createUnsafeProjectPathError(options.inputLabel);
|
|
136
|
+
if (hasParentSegment(configDirName)) throw createUnsafeProjectPathError(options.inputLabel);
|
|
43
137
|
return resolve(root, join(configDirName, options.fileName));
|
|
44
138
|
}
|
|
45
139
|
|
|
@@ -51,11 +145,229 @@ function hasParentSegment(pathInput: string): boolean {
|
|
|
51
145
|
return pathInput.split(/[\\/]+/u).includes(parentSegment);
|
|
52
146
|
}
|
|
53
147
|
|
|
148
|
+
function assertProjectContainment(root: string, candidatePath: string, inputLabel: string): void {
|
|
149
|
+
if (!isPathInsideOrSame(root, candidatePath)) throw createUnsafeProjectPathError(inputLabel);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function createCanonicalProjectPathSnapshot(
|
|
153
|
+
options: ProjectLocalFilePathOptions,
|
|
154
|
+
): Promise<CanonicalProjectPathSnapshot> {
|
|
155
|
+
try {
|
|
156
|
+
const rootPath = resolve(options.cwd ?? process.cwd());
|
|
157
|
+
const candidatePath = resolveProjectLocalCandidatePath(rootPath, options);
|
|
158
|
+
assertProjectContainment(rootPath, candidatePath, options.inputLabel);
|
|
159
|
+
|
|
160
|
+
const canonicalRootPath = await realpath(rootPath);
|
|
161
|
+
const rootStats = await stat(canonicalRootPath, { bigint: true });
|
|
162
|
+
if (!rootStats.isDirectory()) throw createUnsafeProjectPathError(options.inputLabel);
|
|
163
|
+
|
|
164
|
+
const canonicalCandidatePath = await resolveCanonicalPathWithMissingComponents(candidatePath);
|
|
165
|
+
assertProjectContainment(canonicalRootPath, canonicalCandidatePath, options.inputLabel);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
rootPath,
|
|
169
|
+
canonicalRootPath,
|
|
170
|
+
canonicalCandidatePath,
|
|
171
|
+
rootIdentity: toFileIdentity(rootStats),
|
|
172
|
+
inputLabel: options.inputLabel,
|
|
173
|
+
};
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (isUnsafeProjectPathError(error)) throw error;
|
|
176
|
+
throw createUnsafeProjectPathError(options.inputLabel);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function openProjectFileForRead(snapshot: CanonicalProjectPathSnapshot): Promise<FileHandle | undefined> {
|
|
181
|
+
try {
|
|
182
|
+
return await open(snapshot.canonicalCandidatePath, createReadOpenFlags());
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (!isMissingPathError(error) && !isNotDirectoryError(error)) {
|
|
185
|
+
if (isSymlinkLoopError(error)) throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await assertMissingProjectPathIsSafe(snapshot);
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function openProjectFileForExclusiveCreate(
|
|
195
|
+
snapshot: CanonicalProjectPathSnapshot,
|
|
196
|
+
): Promise<FileHandle | undefined> {
|
|
197
|
+
try {
|
|
198
|
+
return await open(snapshot.canonicalCandidatePath, createExclusiveWriteOpenFlags(), 0o600);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (isFileAlreadyExistsError(error)) return undefined;
|
|
201
|
+
if (isSymlinkLoopError(error)) throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function createReadOpenFlags(): number {
|
|
207
|
+
return constants.O_RDONLY | optionalOpenFlag(constants.O_NOFOLLOW) | optionalOpenFlag(constants.O_NONBLOCK);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function createExclusiveWriteOpenFlags(): number {
|
|
211
|
+
return constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | optionalOpenFlag(constants.O_NOFOLLOW);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function optionalOpenFlag(flag: number | undefined): number {
|
|
215
|
+
return typeof flag === "number" ? flag : 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function assertOpenedProjectFileIsStable(
|
|
219
|
+
snapshot: CanonicalProjectPathSnapshot,
|
|
220
|
+
fileHandle: FileHandle,
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
try {
|
|
223
|
+
await assertCanonicalRootIsStable(snapshot);
|
|
224
|
+
const currentCanonicalPath = await realpath(snapshot.canonicalCandidatePath);
|
|
225
|
+
assertProjectContainment(snapshot.canonicalRootPath, currentCanonicalPath, snapshot.inputLabel);
|
|
226
|
+
|
|
227
|
+
const openedStats = await fileHandle.stat({ bigint: true });
|
|
228
|
+
const currentPathStats = await stat(currentCanonicalPath, { bigint: true });
|
|
229
|
+
if (!openedStats.isFile() || !currentPathStats.isFile()) {
|
|
230
|
+
throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
231
|
+
}
|
|
232
|
+
if (!hasSameFileIdentity(openedStats, currentPathStats)) {
|
|
233
|
+
throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (isUnsafeProjectPathError(error)) throw error;
|
|
237
|
+
throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function assertMissingProjectPathIsSafe(snapshot: CanonicalProjectPathSnapshot): Promise<void> {
|
|
242
|
+
try {
|
|
243
|
+
await assertCanonicalRootIsStable(snapshot);
|
|
244
|
+
const currentCanonicalPath = await resolveCanonicalPathWithMissingComponents(
|
|
245
|
+
snapshot.canonicalCandidatePath,
|
|
246
|
+
);
|
|
247
|
+
assertProjectContainment(snapshot.canonicalRootPath, currentCanonicalPath, snapshot.inputLabel);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (isUnsafeProjectPathError(error)) throw error;
|
|
250
|
+
throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function assertExistingProjectFileIsSafe(options: ProjectLocalFilePathOptions): Promise<void> {
|
|
255
|
+
const snapshot = await createCanonicalProjectPathSnapshot(options);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
await assertCanonicalRootIsStable(snapshot);
|
|
259
|
+
const existingStats = await stat(snapshot.canonicalCandidatePath, { bigint: true });
|
|
260
|
+
if (!existingStats.isFile()) throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (isUnsafeProjectPathError(error)) throw error;
|
|
263
|
+
throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function assertCanonicalRootIsStable(snapshot: CanonicalProjectPathSnapshot): Promise<void> {
|
|
268
|
+
const currentCanonicalRoot = await realpath(snapshot.rootPath);
|
|
269
|
+
const currentRootStats = await stat(currentCanonicalRoot, { bigint: true });
|
|
270
|
+
if (!currentRootStats.isDirectory()) throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
271
|
+
if (!hasSameIdentity(snapshot.rootIdentity, toFileIdentity(currentRootStats))) {
|
|
272
|
+
throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function removeCreatedFileIfIdentityMatches(
|
|
277
|
+
snapshot: CanonicalProjectPathSnapshot,
|
|
278
|
+
openedIdentity: FileIdentity,
|
|
279
|
+
): Promise<void> {
|
|
280
|
+
try {
|
|
281
|
+
const currentStats = await lstat(snapshot.canonicalCandidatePath, { bigint: true });
|
|
282
|
+
if (!hasSameIdentity(openedIdentity, toFileIdentity(currentStats))) return;
|
|
283
|
+
await unlink(snapshot.canonicalCandidatePath);
|
|
284
|
+
} catch {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function closeFileHandle(fileHandle: FileHandle | undefined): Promise<void> {
|
|
290
|
+
if (!fileHandle) return;
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
await fileHandle.close();
|
|
294
|
+
} catch {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function toFileIdentity(stats: BigIntStats): FileIdentity {
|
|
300
|
+
return { device: stats.dev, inode: stats.ino };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function hasSameFileIdentity(left: BigIntStats, right: BigIntStats): boolean {
|
|
304
|
+
return hasSameIdentity(toFileIdentity(left), toFileIdentity(right));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function hasSameIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
|
308
|
+
return left.device === right.device && left.inode === right.inode;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function resolveCanonicalPathWithMissingComponents(candidatePath: string): Promise<string> {
|
|
312
|
+
const missingComponents: string[] = [];
|
|
313
|
+
let currentPath = candidatePath;
|
|
314
|
+
|
|
315
|
+
while (true) {
|
|
316
|
+
try {
|
|
317
|
+
const canonicalExistingPath = await realpath(currentPath);
|
|
318
|
+
missingComponents.reverse();
|
|
319
|
+
return resolve(canonicalExistingPath, ...missingComponents);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (!isMissingPathError(error) || await pathEntryExists(currentPath)) throw error;
|
|
322
|
+
const parentPath = dirname(currentPath);
|
|
323
|
+
if (parentPath === currentPath) throw error;
|
|
324
|
+
missingComponents.push(basename(currentPath));
|
|
325
|
+
currentPath = parentPath;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function pathEntryExists(path: string): Promise<boolean> {
|
|
331
|
+
try {
|
|
332
|
+
await lstat(path);
|
|
333
|
+
return true;
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (isMissingPathError(error)) return false;
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isMissingPathError(error: unknown): error is NodeJS.ErrnoException {
|
|
341
|
+
return isErrorWithCode(error) && error.code === "ENOENT";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function isNotDirectoryError(error: unknown): error is NodeJS.ErrnoException {
|
|
345
|
+
return isErrorWithCode(error) && error.code === "ENOTDIR";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isSymlinkLoopError(error: unknown): error is NodeJS.ErrnoException {
|
|
349
|
+
return isErrorWithCode(error) && error.code === "ELOOP";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isFileAlreadyExistsError(error: unknown): error is NodeJS.ErrnoException {
|
|
353
|
+
return isErrorWithCode(error) && error.code === "EEXIST";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isErrorWithCode(error: unknown): error is NodeJS.ErrnoException {
|
|
357
|
+
return typeof error === "object" && error !== null && "code" in error;
|
|
358
|
+
}
|
|
359
|
+
|
|
54
360
|
function isPathInsideOrSame(root: string, candidatePath: string): boolean {
|
|
55
361
|
const pathFromRoot = relative(root, candidatePath);
|
|
56
362
|
return pathFromRoot === "" || (!pathFromRoot.startsWith(parentSegment) && !isAbsolute(pathFromRoot));
|
|
57
363
|
}
|
|
58
364
|
|
|
365
|
+
function createUnsafeProjectPathError(inputLabel: string): NodeJS.ErrnoException {
|
|
366
|
+
const error = new Error(createUnsafeProjectPathMessage(inputLabel)) as NodeJS.ErrnoException;
|
|
367
|
+
error.code = unsafeProjectPathErrorCode;
|
|
368
|
+
return error;
|
|
369
|
+
}
|
|
370
|
+
|
|
59
371
|
function createUnsafeProjectPathMessage(inputLabel: string): string {
|
|
60
372
|
return `Unsafe ObservMe ${inputLabel}: path input must stay inside the active project root.`;
|
|
61
373
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const QUERY_RESULT_COUNT_MINIMUM = 1;
|
|
2
|
+
export const QUERY_RESULT_COUNT_MAXIMUM = 100;
|
|
3
|
+
|
|
4
|
+
export function normalizeQueryResultCount(value: number | undefined): number {
|
|
5
|
+
if (value === undefined || !Number.isFinite(value) || value < QUERY_RESULT_COUNT_MINIMUM) {
|
|
6
|
+
return QUERY_RESULT_COUNT_MINIMUM;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return Math.min(Math.trunc(value), QUERY_RESULT_COUNT_MAXIMUM);
|
|
10
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
+
import { QUERY_RESULT_COUNT_MAXIMUM, QUERY_RESULT_COUNT_MINIMUM } from "./query-limits.ts";
|
|
3
4
|
|
|
4
5
|
const observMeEnvironments = ["production", "development", "test"] as const;
|
|
5
6
|
const privacyPathModes = ["hash", "basename", "full", "drop"] as const;
|
|
@@ -8,8 +9,11 @@ export type ObservMeEnvironment = (typeof observMeEnvironments)[number];
|
|
|
8
9
|
export type OtlpProtocol = "http/protobuf";
|
|
9
10
|
export type PrivacyPathMode = (typeof privacyPathModes)[number];
|
|
10
11
|
|
|
12
|
+
export const ACTIVE_AGENT_LEASE_DURATION_MILLIS_MINIMUM = 10_000;
|
|
13
|
+
export const ACTIVE_AGENT_LEASE_DURATION_MILLIS_MAXIMUM = 300_000;
|
|
14
|
+
export const ACTIVE_AGENT_LEASE_EXPORT_SAFETY_MARGIN_MILLIS = 5_000;
|
|
15
|
+
|
|
11
16
|
export interface OtlpTlsConfig {
|
|
12
|
-
enabled: boolean;
|
|
13
17
|
insecureSkipVerify: boolean;
|
|
14
18
|
}
|
|
15
19
|
|
|
@@ -71,6 +75,7 @@ export interface MetricsConfig {
|
|
|
71
75
|
enabled: boolean;
|
|
72
76
|
exportIntervalMillis: number;
|
|
73
77
|
exportTimeoutMillis: number;
|
|
78
|
+
activeAgentLeaseDurationMillis: number;
|
|
74
79
|
labels?: string[];
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -173,7 +178,6 @@ export interface ObservMeConfig {
|
|
|
173
178
|
enabled: boolean;
|
|
174
179
|
environment: ObservMeEnvironment;
|
|
175
180
|
tenant: string;
|
|
176
|
-
replayOnStart: boolean;
|
|
177
181
|
otlp: OtlpConfig;
|
|
178
182
|
resource: ResourceConfig;
|
|
179
183
|
workflow: WorkflowConfig;
|
|
@@ -192,6 +196,10 @@ const environmentSchema = StringEnum(observMeEnvironments);
|
|
|
192
196
|
const otlpProtocolSchema = Type.Literal("http/protobuf");
|
|
193
197
|
const stringRecordSchema = Type.Record(Type.String(), Type.String());
|
|
194
198
|
const positiveIntegerSchema = Type.Integer({ minimum: 1 });
|
|
199
|
+
const queryResultCountSchema = Type.Integer({
|
|
200
|
+
minimum: QUERY_RESULT_COUNT_MINIMUM,
|
|
201
|
+
maximum: QUERY_RESULT_COUNT_MAXIMUM,
|
|
202
|
+
});
|
|
195
203
|
const ratioSchema = Type.Number({ minimum: 0, maximum: 1 });
|
|
196
204
|
const pathModeSchema = StringEnum(privacyPathModes);
|
|
197
205
|
|
|
@@ -208,7 +216,6 @@ export const observMeConfigSchema = Type.Object(
|
|
|
208
216
|
enabled: Type.Boolean(),
|
|
209
217
|
environment: environmentSchema,
|
|
210
218
|
tenant: Type.String({ minLength: 1 }),
|
|
211
|
-
replayOnStart: Type.Boolean(),
|
|
212
219
|
otlp: Type.Object(
|
|
213
220
|
{
|
|
214
221
|
endpoint: Type.String({ minLength: 1 }),
|
|
@@ -217,7 +224,6 @@ export const observMeConfigSchema = Type.Object(
|
|
|
217
224
|
headers: stringRecordSchema,
|
|
218
225
|
tls: Type.Object(
|
|
219
226
|
{
|
|
220
|
-
enabled: Type.Boolean(),
|
|
221
227
|
insecureSkipVerify: Type.Boolean(),
|
|
222
228
|
},
|
|
223
229
|
{ additionalProperties: false },
|
|
@@ -288,6 +294,10 @@ export const observMeConfigSchema = Type.Object(
|
|
|
288
294
|
enabled: Type.Boolean(),
|
|
289
295
|
exportIntervalMillis: positiveIntegerSchema,
|
|
290
296
|
exportTimeoutMillis: positiveIntegerSchema,
|
|
297
|
+
activeAgentLeaseDurationMillis: Type.Integer({
|
|
298
|
+
minimum: ACTIVE_AGENT_LEASE_DURATION_MILLIS_MINIMUM,
|
|
299
|
+
maximum: ACTIVE_AGENT_LEASE_DURATION_MILLIS_MAXIMUM,
|
|
300
|
+
}),
|
|
291
301
|
labels: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
292
302
|
},
|
|
293
303
|
{ additionalProperties: false },
|
|
@@ -352,10 +362,10 @@ export const observMeConfigSchema = Type.Object(
|
|
|
352
362
|
{
|
|
353
363
|
enabled: Type.Boolean(),
|
|
354
364
|
timeoutMs: positiveIntegerSchema,
|
|
355
|
-
maxLogs:
|
|
356
|
-
maxTraces:
|
|
357
|
-
maxMetricSeries:
|
|
358
|
-
maxAgents:
|
|
365
|
+
maxLogs: queryResultCountSchema,
|
|
366
|
+
maxTraces: queryResultCountSchema,
|
|
367
|
+
maxMetricSeries: queryResultCountSchema,
|
|
368
|
+
maxAgents: queryResultCountSchema,
|
|
359
369
|
links: Type.Object(
|
|
360
370
|
{
|
|
361
371
|
traceUrlTemplate: Type.String(),
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ObservMeConfig } from "./schema.ts";
|
|
2
|
+
|
|
3
|
+
const tlsVerificationEnabled = "TLS certificate verification enabled";
|
|
4
|
+
const tlsVerificationDisabled = "TLS certificate verification disabled";
|
|
5
|
+
const plaintextHttp = "plaintext HTTP";
|
|
6
|
+
const inactiveObservMe = "inactive (ObservMe disabled)";
|
|
7
|
+
const inactiveSignals = "inactive (all OTLP signals disabled)";
|
|
8
|
+
const inactiveGrafana = "inactive (Grafana queries disabled)";
|
|
9
|
+
const unknownTransport = "unknown or invalid transport";
|
|
10
|
+
|
|
11
|
+
interface OtlpSignalTransport {
|
|
12
|
+
readonly name: "traces" | "metrics" | "logs";
|
|
13
|
+
readonly endpoint: string;
|
|
14
|
+
readonly enabled: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ObsTransportSecuritySnapshot {
|
|
18
|
+
readonly collector: string;
|
|
19
|
+
readonly grafana: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function describeOtlpTransportSecurity(config: ObservMeConfig): string {
|
|
23
|
+
if (!config.enabled) return inactiveObservMe;
|
|
24
|
+
|
|
25
|
+
const signals = getOtlpSignalTransports(config).filter(signal => signal.enabled);
|
|
26
|
+
if (signals.length === 0) return inactiveSignals;
|
|
27
|
+
|
|
28
|
+
const descriptions = signals.map(signal => ({
|
|
29
|
+
name: signal.name,
|
|
30
|
+
description: describeEndpointTransportSecurity(
|
|
31
|
+
signal.endpoint,
|
|
32
|
+
config.otlp.tls.insecureSkipVerify,
|
|
33
|
+
config.privacy.allowInsecureTransport,
|
|
34
|
+
),
|
|
35
|
+
}));
|
|
36
|
+
const uniqueDescriptions = new Set(descriptions.map(signal => signal.description));
|
|
37
|
+
if (uniqueDescriptions.size === 1) return descriptions[0]?.description ?? unknownTransport;
|
|
38
|
+
|
|
39
|
+
return descriptions.map(signal => `${signal.name}: ${signal.description}`).join(", ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function describeGrafanaTransportSecurity(config: ObservMeConfig): string {
|
|
43
|
+
if (!config.query.enabled) return inactiveGrafana;
|
|
44
|
+
return describeEndpointTransportSecurity(
|
|
45
|
+
config.query.grafana.url,
|
|
46
|
+
config.query.grafana.tls.insecureSkipVerify,
|
|
47
|
+
config.privacy.allowInsecureTransport,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createObsTransportSecuritySnapshot(config: ObservMeConfig): ObsTransportSecuritySnapshot {
|
|
52
|
+
return {
|
|
53
|
+
collector: describeEndpointTransportSecurity(
|
|
54
|
+
config.otlp.endpoint,
|
|
55
|
+
config.otlp.tls.insecureSkipVerify,
|
|
56
|
+
config.privacy.allowInsecureTransport,
|
|
57
|
+
),
|
|
58
|
+
grafana: describeGrafanaTransportSecurity(config),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function describeEndpointTransportSecurity(
|
|
63
|
+
endpoint: string,
|
|
64
|
+
insecureSkipVerify: boolean,
|
|
65
|
+
allowInsecureTransport: boolean,
|
|
66
|
+
): string {
|
|
67
|
+
const protocol = readEndpointProtocol(endpoint);
|
|
68
|
+
if (protocol === "https:") {
|
|
69
|
+
return insecureSkipVerify
|
|
70
|
+
? describeAcknowledgedInsecurity(tlsVerificationDisabled, allowInsecureTransport)
|
|
71
|
+
: tlsVerificationEnabled;
|
|
72
|
+
}
|
|
73
|
+
if (protocol === "http:") return describeAcknowledgedInsecurity(plaintextHttp, allowInsecureTransport);
|
|
74
|
+
return unknownTransport;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getOtlpSignalTransports(config: ObservMeConfig): OtlpSignalTransport[] {
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
name: "traces",
|
|
81
|
+
endpoint: config.otlp.signalEndpoints?.traces ?? config.otlp.endpoint,
|
|
82
|
+
enabled: config.traces.enabled,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "metrics",
|
|
86
|
+
endpoint: config.otlp.signalEndpoints?.metrics ?? config.otlp.endpoint,
|
|
87
|
+
enabled: config.metrics.enabled,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "logs",
|
|
91
|
+
endpoint: config.otlp.signalEndpoints?.logs ?? config.otlp.endpoint,
|
|
92
|
+
enabled: config.logs.enabled,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readEndpointProtocol(endpoint: string): string | undefined {
|
|
98
|
+
try {
|
|
99
|
+
return new URL(endpoint.trim()).protocol;
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function describeAcknowledgedInsecurity(description: string, allowInsecureTransport: boolean): string {
|
|
106
|
+
return allowInsecureTransport ? `${description} (explicitly acknowledged)` : description;
|
|
107
|
+
}
|