@senad-d/observme 0.1.4 → 0.1.6
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/CHANGELOG.md +35 -0
- package/README.md +24 -8
- package/docs/agent-subagent-observability-requirements.md +4 -3
- package/docs/compatibility-matrix.md +14 -3
- package/docs/configuration.md +2 -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 +1 -0
- package/docs/reference/06-security-privacy-redaction.md +13 -6
- package/docs/reference/08-query-grafana-integration.md +30 -7
- package/docs/reference/11-deployment-runbooks.md +21 -0
- package/docs/reference/12-configuration-reference.md +30 -4
- package/docs/review-validation.md +10 -0
- package/examples/integrations/subagent-runner.ts +8 -5
- package/examples/observme.yaml +2 -2
- package/package.json +12 -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 +49 -32
- package/src/config/defaults.ts +0 -2
- package/src/config/load-config.ts +270 -92
- package/src/config/project-paths.ts +318 -6
- package/src/config/query-limits.ts +10 -0
- package/src/config/schema.ts +9 -8
- package/src/config/transport-security.ts +107 -0
- package/src/config/validate.ts +68 -11
- 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/agent-tree-tracker.ts +14 -1
- package/src/pi/compatibility.ts +30 -0
- package/src/pi/event-handlers/agent-turn.ts +16 -9
- package/src/pi/event-handlers/lifecycle.ts +207 -75
- 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 +142 -55
- package/src/pi/handler-types.ts +30 -15
- 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 +1 -0
- package/src/semconv/values.ts +2 -0
|
@@ -3,9 +3,22 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { defaultObservMeConfig } from "./defaults.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
isUnsafeProjectPathError,
|
|
8
|
+
readCanonicalProjectLocalFileText,
|
|
9
|
+
resolveProjectLocalFilePath,
|
|
10
|
+
} from "./project-paths.ts";
|
|
11
|
+
import type {
|
|
12
|
+
ProjectLocalFileOperationHooks,
|
|
13
|
+
ProjectLocalFilePathOptions,
|
|
14
|
+
} from "./project-paths.ts";
|
|
7
15
|
import type { ConfigLogSink, ConfigRejectionDiagnostic } from "./validate.ts";
|
|
8
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
ensureValidObservMeConfig,
|
|
18
|
+
ensureValidObservMeConfigWithDiagnostics,
|
|
19
|
+
normalizeConfigRejectionDiagnostic,
|
|
20
|
+
validateObservMeConfig,
|
|
21
|
+
} from "./validate.ts";
|
|
9
22
|
import type { ObservMeConfig } from "./schema.ts";
|
|
10
23
|
import { registerTenantSaltEnvironment } from "../privacy/hash.ts";
|
|
11
24
|
import { RESOURCE_ATTRIBUTES } from "../semconv/attributes.ts";
|
|
@@ -22,6 +35,19 @@ type ConfigValue = null | boolean | number | string | ConfigObject | ConfigValue
|
|
|
22
35
|
type ConfigObject = { [key: string]: ConfigValue };
|
|
23
36
|
|
|
24
37
|
type ReadConfigText = (path: string) => Promise<string | undefined>;
|
|
38
|
+
type ConfigSourceIssueCode =
|
|
39
|
+
| "config_source_malformed"
|
|
40
|
+
| "config_source_rejected"
|
|
41
|
+
| "config_source_unreadable"
|
|
42
|
+
| "invalid_config_shape";
|
|
43
|
+
|
|
44
|
+
type BaseSessionConfigSourceStatus = "loaded" | "malformed" | "missing" | "rejected" | "unreadable";
|
|
45
|
+
|
|
46
|
+
interface ConfigSourceResult<T, TStatus extends string = BaseSessionConfigSourceStatus> {
|
|
47
|
+
readonly status: TStatus;
|
|
48
|
+
readonly value?: T;
|
|
49
|
+
readonly issueCode?: ConfigSourceIssueCode;
|
|
50
|
+
}
|
|
25
51
|
|
|
26
52
|
export interface ProjectTrustContext {
|
|
27
53
|
isProjectTrusted?: () => boolean | Promise<boolean>;
|
|
@@ -35,6 +61,11 @@ export interface LoadConfigOptions {
|
|
|
35
61
|
logger?: ConfigLogSink;
|
|
36
62
|
}
|
|
37
63
|
|
|
64
|
+
export interface ProjectFileOperationHooks {
|
|
65
|
+
readonly projectConfig?: ProjectLocalFileOperationHooks;
|
|
66
|
+
readonly environmentFile?: ProjectLocalFileOperationHooks;
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
export interface LoadSessionConfigOptions extends LoadConfigOptions {
|
|
39
70
|
ctx?: ProjectTrustContext;
|
|
40
71
|
cwd?: string;
|
|
@@ -43,14 +74,20 @@ export interface LoadSessionConfigOptions extends LoadConfigOptions {
|
|
|
43
74
|
envFilePath?: string;
|
|
44
75
|
loadEnvFile?: boolean;
|
|
45
76
|
isProjectTrusted?: boolean | (() => boolean | Promise<boolean>);
|
|
77
|
+
projectFileOperationHooks?: ProjectFileOperationHooks;
|
|
46
78
|
}
|
|
47
79
|
|
|
48
|
-
export type SessionConfigProjectStatus =
|
|
80
|
+
export type SessionConfigProjectStatus = BaseSessionConfigSourceStatus | "skipped_untrusted";
|
|
81
|
+
export type SessionConfigEnvFileStatus = BaseSessionConfigSourceStatus | "skipped_disabled" | "skipped_untrusted";
|
|
82
|
+
export type SessionConfigEnvironmentStatus = "loaded" | "missing" | "rejected";
|
|
49
83
|
export type SessionConfigEffectiveSource = "runtime_options" | "environment" | "trusted_project" | "global" | "defaults";
|
|
50
84
|
|
|
51
85
|
export interface SessionConfigDiagnostics {
|
|
52
86
|
readonly projectTrusted: boolean;
|
|
53
87
|
readonly projectConfigStatus: SessionConfigProjectStatus;
|
|
88
|
+
readonly globalConfigStatus?: BaseSessionConfigSourceStatus;
|
|
89
|
+
readonly envFileStatus?: SessionConfigEnvFileStatus;
|
|
90
|
+
readonly environmentStatus?: SessionConfigEnvironmentStatus;
|
|
54
91
|
readonly effectiveSource: SessionConfigEffectiveSource;
|
|
55
92
|
readonly globalConfigLoaded: boolean;
|
|
56
93
|
readonly environmentOverrides: boolean;
|
|
@@ -86,18 +123,22 @@ export async function loadSessionConfig(options: LoadSessionConfigOptions = {}):
|
|
|
86
123
|
}
|
|
87
124
|
|
|
88
125
|
export async function loadSessionConfigWithDiagnostics(options: LoadSessionConfigOptions = {}): Promise<LoadSessionConfigResult> {
|
|
89
|
-
const
|
|
126
|
+
const globalSource = classifyConfigSource(
|
|
127
|
+
await readConfigSource(resolveGlobalConfigPath(options), options),
|
|
128
|
+
);
|
|
90
129
|
const projectTrusted = await resolveProjectTrust(options);
|
|
91
|
-
const
|
|
92
|
-
const
|
|
130
|
+
const projectSource = classifyConfigSource(await readProjectConfigSource(projectTrusted, options));
|
|
131
|
+
const envFileSource = await readTrustedProjectEnvSource(projectTrusted, options);
|
|
93
132
|
const environment = options.env ?? process.env;
|
|
94
|
-
const
|
|
95
|
-
const
|
|
133
|
+
const envFileConfig = envFileSource.value ? envToConfig(envFileSource.value) : undefined;
|
|
134
|
+
const classifiedEnvFileSource = classifyConfigSource(envFileSource, envFileConfig);
|
|
96
135
|
const environmentConfig = envToConfig(environment);
|
|
136
|
+
const environmentSource = classifyEnvironmentSource(environment, environmentConfig);
|
|
137
|
+
const effectiveEnvironment = mergeEnvironment(classifiedEnvFileSource.value, environment);
|
|
97
138
|
const config = mergeConfigLayers([
|
|
98
139
|
defaultObservMeConfig,
|
|
99
|
-
|
|
100
|
-
|
|
140
|
+
globalSource.value,
|
|
141
|
+
projectSource.value,
|
|
101
142
|
envFileConfig,
|
|
102
143
|
environmentConfig,
|
|
103
144
|
options.runtimeOptions,
|
|
@@ -105,17 +146,25 @@ export async function loadSessionConfigWithDiagnostics(options: LoadSessionConfi
|
|
|
105
146
|
const validation = ensureValidObservMeConfigWithDiagnostics(config, {
|
|
106
147
|
env: effectiveEnvironment,
|
|
107
148
|
isProjectTrusted: projectTrusted,
|
|
108
|
-
projectConfigWasRead:
|
|
109
|
-
logger: options.logger,
|
|
149
|
+
projectConfigWasRead: projectSource.value !== undefined,
|
|
110
150
|
});
|
|
151
|
+
const rejection = combineConfigRejections(validation.rejection, [
|
|
152
|
+
globalSource,
|
|
153
|
+
projectSource,
|
|
154
|
+
classifiedEnvFileSource,
|
|
155
|
+
environmentSource,
|
|
156
|
+
]);
|
|
157
|
+
logSessionConfigRejection(rejection, options.logger);
|
|
111
158
|
const diagnostics = createSessionConfigDiagnostics({
|
|
112
|
-
|
|
113
|
-
|
|
159
|
+
globalSource,
|
|
160
|
+
projectSource,
|
|
161
|
+
envFileSource: classifiedEnvFileSource,
|
|
162
|
+
environmentSource,
|
|
114
163
|
projectTrusted,
|
|
115
164
|
envFileConfig,
|
|
116
165
|
environmentConfig,
|
|
117
166
|
runtimeOptions: options.runtimeOptions,
|
|
118
|
-
rejection
|
|
167
|
+
rejection,
|
|
119
168
|
});
|
|
120
169
|
|
|
121
170
|
return {
|
|
@@ -140,18 +189,13 @@ export function envToConfig(env: NodeJS.ProcessEnv = process.env): DeepPartial<O
|
|
|
140
189
|
setBoolean(config, ["enabled"], env.OBSERVME_ENABLED);
|
|
141
190
|
setString(config, ["environment"], env.OBSERVME_ENVIRONMENT);
|
|
142
191
|
setString(config, ["tenant"], env.OBSERVME_TENANT);
|
|
143
|
-
setBoolean(config, ["replayOnStart"], env.OBSERVME_REPLAY_ON_START);
|
|
144
192
|
setString(config, ["otlp", "endpoint"], env.OBSERVME_OTLP_ENDPOINT);
|
|
145
193
|
setString(config, ["otlp", "protocol"], env.OBSERVME_OTLP_PROTOCOL);
|
|
146
194
|
setString(config, ["otlp", "signalEndpoints", "traces"], env.OBSERVME_OTLP_TRACES_ENDPOINT);
|
|
147
195
|
setString(config, ["otlp", "signalEndpoints", "metrics"], env.OBSERVME_OTLP_METRICS_ENDPOINT);
|
|
148
196
|
setString(config, ["otlp", "signalEndpoints", "logs"], env.OBSERVME_OTLP_LOGS_ENDPOINT);
|
|
149
197
|
setNumber(config, ["otlp", "timeoutMs"], env.OBSERVME_OTLP_TIMEOUT_MS);
|
|
150
|
-
|
|
151
|
-
config,
|
|
152
|
-
["metrics", "activeAgentLeaseDurationMillis"],
|
|
153
|
-
env.OBSERVME_ACTIVE_AGENT_LEASE_DURATION_MS,
|
|
154
|
-
);
|
|
198
|
+
setNumber(config, ["metrics", "activeAgentLeaseDurationMillis"], env.OBSERVME_ACTIVE_AGENT_LEASE_DURATION_MS);
|
|
155
199
|
setAuthorizationHeader(config, env.OBSERVME_OTLP_TOKEN);
|
|
156
200
|
setNumber(config, ["workflow", "maxDepthWarning"], env.OBSERVME_WORKFLOW_MAX_DEPTH_WARNING);
|
|
157
201
|
setNumber(config, ["workflow", "maxFanoutWarning"], env.OBSERVME_WORKFLOW_MAX_FANOUT_WARNING);
|
|
@@ -191,43 +235,38 @@ export function envToConfig(env: NodeJS.ProcessEnv = process.env): DeepPartial<O
|
|
|
191
235
|
}
|
|
192
236
|
|
|
193
237
|
function createSessionConfigDiagnostics(options: {
|
|
194
|
-
readonly
|
|
195
|
-
readonly
|
|
238
|
+
readonly globalSource: ConfigSourceResult<DeepPartial<ObservMeConfig>>;
|
|
239
|
+
readonly projectSource: ConfigSourceResult<DeepPartial<ObservMeConfig>>;
|
|
240
|
+
readonly envFileSource: ConfigSourceResult<NodeJS.ProcessEnv, SessionConfigEnvFileStatus>;
|
|
241
|
+
readonly environmentSource: ConfigSourceResult<DeepPartial<ObservMeConfig>, SessionConfigEnvironmentStatus>;
|
|
196
242
|
readonly projectTrusted: boolean;
|
|
197
243
|
readonly envFileConfig?: DeepPartial<ObservMeConfig>;
|
|
198
244
|
readonly environmentConfig: DeepPartial<ObservMeConfig>;
|
|
199
245
|
readonly runtimeOptions?: DeepPartial<ObservMeConfig>;
|
|
200
246
|
readonly rejection?: ConfigRejectionDiagnostic;
|
|
201
247
|
}): SessionConfigDiagnostics {
|
|
202
|
-
const globalConfigLoaded = options.globalConfig !== undefined;
|
|
203
|
-
const projectConfigStatus = resolveSessionConfigProjectStatus(options.projectTrusted, options.projectConfig);
|
|
204
248
|
const environmentOverrides = hasConfigLayer(options.envFileConfig) || hasConfigLayer(options.environmentConfig);
|
|
205
249
|
const runtimeOptionsApplied = hasConfigLayer(options.runtimeOptions);
|
|
206
250
|
|
|
207
251
|
return {
|
|
208
252
|
projectTrusted: options.projectTrusted,
|
|
209
|
-
projectConfigStatus,
|
|
253
|
+
projectConfigStatus: options.projectTrusted ? options.projectSource.status : "skipped_untrusted",
|
|
254
|
+
globalConfigStatus: options.globalSource.status,
|
|
255
|
+
envFileStatus: options.envFileSource.status,
|
|
256
|
+
environmentStatus: options.environmentSource.status,
|
|
210
257
|
effectiveSource: resolveSessionConfigEffectiveSource({
|
|
211
|
-
globalConfig: options.
|
|
212
|
-
projectConfig: options.
|
|
258
|
+
globalConfig: options.globalSource.value,
|
|
259
|
+
projectConfig: options.projectSource.value,
|
|
213
260
|
environmentOverrides,
|
|
214
261
|
runtimeOptionsApplied,
|
|
215
262
|
}),
|
|
216
|
-
globalConfigLoaded,
|
|
263
|
+
globalConfigLoaded: options.globalSource.status === "loaded",
|
|
217
264
|
environmentOverrides,
|
|
218
265
|
runtimeOptionsApplied,
|
|
219
266
|
rejection: options.rejection,
|
|
220
267
|
};
|
|
221
268
|
}
|
|
222
269
|
|
|
223
|
-
function resolveSessionConfigProjectStatus(
|
|
224
|
-
projectTrusted: boolean,
|
|
225
|
-
projectConfig: DeepPartial<ObservMeConfig> | undefined,
|
|
226
|
-
): SessionConfigProjectStatus {
|
|
227
|
-
if (!projectTrusted) return "skipped_untrusted";
|
|
228
|
-
return projectConfig === undefined ? "missing" : "loaded";
|
|
229
|
-
}
|
|
230
|
-
|
|
231
270
|
function resolveSessionConfigEffectiveSource(options: {
|
|
232
271
|
readonly globalConfig?: DeepPartial<ObservMeConfig>;
|
|
233
272
|
readonly projectConfig?: DeepPartial<ObservMeConfig>;
|
|
@@ -245,6 +284,92 @@ function hasConfigLayer(layer: DeepPartial<ObservMeConfig> | undefined): boolean
|
|
|
245
284
|
return layer !== undefined && Object.keys(layer).length > 0;
|
|
246
285
|
}
|
|
247
286
|
|
|
287
|
+
function classifyConfigSource<T, TStatus extends string>(
|
|
288
|
+
source: ConfigSourceResult<T, TStatus>,
|
|
289
|
+
configLayer?: DeepPartial<ObservMeConfig>,
|
|
290
|
+
): ConfigSourceResult<T, TStatus> {
|
|
291
|
+
if (source.status !== "loaded") return source;
|
|
292
|
+
|
|
293
|
+
const layer = configLayer ?? (source.value as DeepPartial<ObservMeConfig> | undefined);
|
|
294
|
+
if (!layer || !isConfigLayerStructurallyInvalid(layer)) return source;
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
...source,
|
|
298
|
+
status: "rejected" as TStatus,
|
|
299
|
+
issueCode: "invalid_config_shape",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function classifyEnvironmentSource(
|
|
304
|
+
environment: NodeJS.ProcessEnv,
|
|
305
|
+
config: DeepPartial<ObservMeConfig>,
|
|
306
|
+
): ConfigSourceResult<DeepPartial<ObservMeConfig>, SessionConfigEnvironmentStatus> {
|
|
307
|
+
if (!hasObservMeEnvironmentInput(environment)) return { status: "missing", value: config };
|
|
308
|
+
if (isConfigLayerStructurallyInvalid(config)) {
|
|
309
|
+
return { status: "rejected", value: config, issueCode: "invalid_config_shape" };
|
|
310
|
+
}
|
|
311
|
+
return { status: "loaded", value: config };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function hasObservMeEnvironmentInput(environment: NodeJS.ProcessEnv): boolean {
|
|
315
|
+
return Object.keys(environment).some(name => name.startsWith("OBSERVME_"));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isConfigLayerStructurallyInvalid(layer: DeepPartial<ObservMeConfig>): boolean {
|
|
319
|
+
try {
|
|
320
|
+
const candidate = mergeConfigLayers([defaultObservMeConfig, layer]);
|
|
321
|
+
return validateObservMeConfig(candidate, { env: {}, isProjectTrusted: true }).issues.some(
|
|
322
|
+
issue => issue.code === "invalid_config_shape",
|
|
323
|
+
);
|
|
324
|
+
} catch {
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function combineConfigRejections(
|
|
330
|
+
validationRejection: ConfigRejectionDiagnostic | undefined,
|
|
331
|
+
sources: ReadonlyArray<ConfigSourceResult<unknown, string>>,
|
|
332
|
+
): ConfigRejectionDiagnostic | undefined {
|
|
333
|
+
const sourceIssueCodes = sources.flatMap(source => (source.issueCode ? [source.issueCode] : []));
|
|
334
|
+
if (sourceIssueCodes.length === 0) return validationRejection;
|
|
335
|
+
|
|
336
|
+
const validationCodes = validationRejection?.issueCodes ?? [];
|
|
337
|
+
const additionalIssueCount = sourceIssueCodes.filter(code => !validationCodes.includes(code)).length;
|
|
338
|
+
return normalizeConfigRejectionDiagnostic({
|
|
339
|
+
issueCodes: [...validationCodes, ...sourceIssueCodes],
|
|
340
|
+
issueCount: (validationRejection?.issueCount ?? 0) + additionalIssueCount,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function logSessionConfigRejection(
|
|
345
|
+
rejection: ConfigRejectionDiagnostic | undefined,
|
|
346
|
+
logger: ConfigLogSink | undefined,
|
|
347
|
+
): void {
|
|
348
|
+
if (!rejection || !logger?.warn) return;
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
logger.warn(
|
|
352
|
+
`ObservMe config rejected (${rejection.issueCodes.join(", ")}); ${rejection.issueCount} issue(s), safe defaults applied.`,
|
|
353
|
+
);
|
|
354
|
+
} catch {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function logConfigSourceFailure(
|
|
360
|
+
label: string,
|
|
361
|
+
source: ConfigSourceResult<unknown, string>,
|
|
362
|
+
logger: ConfigLogSink | undefined,
|
|
363
|
+
): void {
|
|
364
|
+
if (!source.issueCode || !logger?.warn) return;
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
logger.warn(`ObservMe ${label} source was ignored (${source.issueCode}); safe defaults applied.`);
|
|
368
|
+
} catch {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
248
373
|
export function parseObservMeConfigText(text: string): DeepPartial<ObservMeConfig> {
|
|
249
374
|
const parsed = text.trimStart().startsWith("{") ? (JSON.parse(text) as ConfigObject) : parseSimpleYaml(text);
|
|
250
375
|
const observmeConfig = parsed.observme;
|
|
@@ -257,49 +382,96 @@ async function readConfigFile(
|
|
|
257
382
|
path: string,
|
|
258
383
|
options: LoadConfigOptions,
|
|
259
384
|
): Promise<DeepPartial<ObservMeConfig> | undefined> {
|
|
385
|
+
const source = classifyConfigSource(await readConfigSource(path, options));
|
|
386
|
+
logConfigSourceFailure("global config", source, options.logger);
|
|
387
|
+
return source.value;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function readConfigSource(
|
|
391
|
+
path: string,
|
|
392
|
+
options: LoadConfigOptions,
|
|
393
|
+
): Promise<ConfigSourceResult<DeepPartial<ObservMeConfig>>> {
|
|
394
|
+
const textResult = await readConfigSourceText(path, options);
|
|
395
|
+
if (textResult.status !== "loaded") {
|
|
396
|
+
return { status: textResult.status, issueCode: textResult.issueCode };
|
|
397
|
+
}
|
|
398
|
+
|
|
260
399
|
try {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
return
|
|
264
|
-
} catch (error) {
|
|
265
|
-
if (isMissingFileError(error)) return undefined;
|
|
266
|
-
options.logger?.warn?.(`ObservMe config file ${path} was ignored: ${formatError(error)}`);
|
|
267
|
-
return undefined;
|
|
400
|
+
return { status: "loaded", value: parseObservMeConfigText(textResult.value!) };
|
|
401
|
+
} catch {
|
|
402
|
+
return { status: "malformed", issueCode: "config_source_malformed" };
|
|
268
403
|
}
|
|
269
404
|
}
|
|
270
405
|
|
|
271
|
-
async function
|
|
406
|
+
async function readProjectConfigSource(
|
|
407
|
+
projectTrusted: boolean,
|
|
408
|
+
options: LoadSessionConfigOptions,
|
|
409
|
+
): Promise<ConfigSourceResult<DeepPartial<ObservMeConfig>>> {
|
|
410
|
+
if (!projectTrusted) return { status: "missing" };
|
|
411
|
+
|
|
272
412
|
try {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
413
|
+
const pathOptions = createProjectConfigPathOptions(options);
|
|
414
|
+
const readOptions = createProjectSourceReadOptions(
|
|
415
|
+
options,
|
|
416
|
+
pathOptions,
|
|
417
|
+
options.projectFileOperationHooks?.projectConfig,
|
|
418
|
+
);
|
|
419
|
+
return await readConfigSource(resolveProjectLocalFilePath(pathOptions), readOptions);
|
|
420
|
+
} catch {
|
|
421
|
+
return { status: "rejected", issueCode: "config_source_rejected" };
|
|
277
422
|
}
|
|
278
423
|
}
|
|
279
424
|
|
|
280
|
-
async function
|
|
425
|
+
async function readTrustedProjectEnvSource(
|
|
281
426
|
projectTrusted: boolean,
|
|
282
427
|
options: LoadSessionConfigOptions,
|
|
283
|
-
): Promise<NodeJS.ProcessEnv
|
|
284
|
-
if (!projectTrusted
|
|
428
|
+
): Promise<ConfigSourceResult<NodeJS.ProcessEnv, SessionConfigEnvFileStatus>> {
|
|
429
|
+
if (!projectTrusted) return { status: "skipped_untrusted" };
|
|
430
|
+
if (options.loadEnvFile === false) return { status: "skipped_disabled" };
|
|
285
431
|
|
|
286
432
|
try {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
433
|
+
const pathOptions = createProjectEnvFilePathOptions(options);
|
|
434
|
+
const readOptions = createProjectSourceReadOptions(
|
|
435
|
+
options,
|
|
436
|
+
pathOptions,
|
|
437
|
+
options.projectFileOperationHooks?.environmentFile,
|
|
438
|
+
);
|
|
439
|
+
return await readEnvSource(resolveProjectLocalFilePath(pathOptions), readOptions);
|
|
440
|
+
} catch {
|
|
441
|
+
return { status: "rejected", issueCode: "config_source_rejected" };
|
|
291
442
|
}
|
|
292
443
|
}
|
|
293
444
|
|
|
294
|
-
async function
|
|
445
|
+
async function readEnvSource(
|
|
446
|
+
path: string,
|
|
447
|
+
options: LoadConfigOptions,
|
|
448
|
+
): Promise<ConfigSourceResult<NodeJS.ProcessEnv>> {
|
|
449
|
+
const textResult = await readConfigSourceText(path, options);
|
|
450
|
+
if (textResult.status !== "loaded") {
|
|
451
|
+
return { status: textResult.status, issueCode: textResult.issueCode };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
return { status: "loaded", value: parseEnvFileText(textResult.value!) };
|
|
456
|
+
} catch {
|
|
457
|
+
return { status: "malformed", issueCode: "config_source_malformed" };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function readConfigSourceText(
|
|
462
|
+
path: string,
|
|
463
|
+
options: LoadConfigOptions,
|
|
464
|
+
): Promise<ConfigSourceResult<string>> {
|
|
295
465
|
try {
|
|
296
466
|
const text = await readOptionalText(path, options.readText ?? defaultReadConfigText);
|
|
297
|
-
if (
|
|
298
|
-
|
|
467
|
+
if (text === undefined) return { status: "missing" };
|
|
468
|
+
if (text.trim() === "") return { status: "malformed", issueCode: "config_source_malformed" };
|
|
469
|
+
return { status: "loaded", value: text };
|
|
299
470
|
} catch (error) {
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
471
|
+
if (isUnsafeProjectPathError(error)) {
|
|
472
|
+
return { status: "rejected", issueCode: "config_source_rejected" };
|
|
473
|
+
}
|
|
474
|
+
return { status: "unreadable", issueCode: "config_source_unreadable" };
|
|
303
475
|
}
|
|
304
476
|
}
|
|
305
477
|
|
|
@@ -320,28 +492,36 @@ function resolveGlobalConfigPath(options: LoadConfigOptions): string {
|
|
|
320
492
|
return options.globalConfigPath ?? join(homedir(), CONFIG_DIR_NAME, "agent", observmeYamlFileName);
|
|
321
493
|
}
|
|
322
494
|
|
|
323
|
-
function
|
|
324
|
-
|
|
495
|
+
function createProjectSourceReadOptions(
|
|
496
|
+
options: LoadSessionConfigOptions,
|
|
497
|
+
pathOptions: ProjectLocalFilePathOptions,
|
|
498
|
+
hooks: ProjectLocalFileOperationHooks | undefined,
|
|
499
|
+
): LoadConfigOptions {
|
|
500
|
+
if (options.readText !== undefined) return options;
|
|
501
|
+
return {
|
|
502
|
+
...options,
|
|
503
|
+
readText: readCanonicalProjectLocalFileText.bind(undefined, pathOptions, hooks),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function createProjectConfigPathOptions(options: LoadSessionConfigOptions): ProjectLocalFilePathOptions {
|
|
508
|
+
return {
|
|
325
509
|
cwd: options.cwd,
|
|
326
510
|
configDirName: options.configDirName,
|
|
327
511
|
defaultConfigDirName: CONFIG_DIR_NAME,
|
|
328
512
|
fileName: observmeYamlFileName,
|
|
329
513
|
overridePath: options.projectConfigPath,
|
|
330
514
|
inputLabel: "project config path",
|
|
331
|
-
}
|
|
515
|
+
};
|
|
332
516
|
}
|
|
333
517
|
|
|
334
|
-
function
|
|
335
|
-
return
|
|
518
|
+
function createProjectEnvFilePathOptions(options: LoadSessionConfigOptions): ProjectLocalFilePathOptions {
|
|
519
|
+
return {
|
|
336
520
|
cwd: options.cwd,
|
|
337
521
|
fileName: defaultEnvFileName,
|
|
338
522
|
overridePath: options.envFilePath ?? defaultEnvFileName,
|
|
339
523
|
inputLabel: "project env path",
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function warnIgnoredProjectPath(options: LoadSessionConfigOptions, source: "config" | "env", error: unknown) {
|
|
344
|
-
options.logger?.warn?.(`ObservMe project ${source} file was ignored: ${formatError(error)}`);
|
|
524
|
+
};
|
|
345
525
|
}
|
|
346
526
|
|
|
347
527
|
async function resolveProjectTrust(options: LoadSessionConfigOptions): Promise<boolean> {
|
|
@@ -417,9 +597,17 @@ function parseEnvValue(valueText: string): string {
|
|
|
417
597
|
function parseQuotedEnvValue(valueText: string): string {
|
|
418
598
|
const closingIndex = findClosingEnvQuote(valueText);
|
|
419
599
|
if (closingIndex === -1) throw new Error("Invalid .env quoted value.");
|
|
600
|
+
if (!isValidQuotedEnvValueSuffix(valueText.slice(closingIndex + 1))) {
|
|
601
|
+
throw new Error("Invalid .env quoted value suffix.");
|
|
602
|
+
}
|
|
420
603
|
return unquoteEnvValue(valueText.slice(0, closingIndex + 1));
|
|
421
604
|
}
|
|
422
605
|
|
|
606
|
+
function isValidQuotedEnvValueSuffix(suffix: string): boolean {
|
|
607
|
+
if (suffix.trim() === "") return true;
|
|
608
|
+
return /^\s+#/u.test(suffix);
|
|
609
|
+
}
|
|
610
|
+
|
|
423
611
|
function findClosingEnvQuote(valueText: string): number {
|
|
424
612
|
const quote = valueText[0];
|
|
425
613
|
|
|
@@ -586,27 +774,22 @@ function setAuthorizationHeader(config: DeepPartial<ObservMeConfig>, token: stri
|
|
|
586
774
|
}
|
|
587
775
|
|
|
588
776
|
function setString(target: DeepPartial<ObservMeConfig>, path: string[], value: string | undefined) {
|
|
589
|
-
if (value === undefined
|
|
777
|
+
if (value === undefined) return;
|
|
590
778
|
setPathValue(target as Record<string, unknown>, path, value);
|
|
591
779
|
}
|
|
592
780
|
|
|
593
781
|
function setNumber(target: DeepPartial<ObservMeConfig>, path: string[], value: string | undefined) {
|
|
594
|
-
if (value === undefined
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
function setNumberForValidation(target: DeepPartial<ObservMeConfig>, path: string[], value: string | undefined) {
|
|
601
|
-
if (value === undefined || value === "") return;
|
|
602
|
-
const parsed = Number(value);
|
|
603
|
-
setPathValue(target as Record<string, unknown>, path, Number.isFinite(parsed) ? parsed : value);
|
|
782
|
+
if (value === undefined) return;
|
|
783
|
+
const normalized = value.trim();
|
|
784
|
+
const parsed = Number(normalized);
|
|
785
|
+
const parsedValue = normalized !== "" && Number.isFinite(parsed) ? parsed : value;
|
|
786
|
+
setPathValue(target as Record<string, unknown>, path, parsedValue);
|
|
604
787
|
}
|
|
605
788
|
|
|
606
789
|
function setBoolean(target: DeepPartial<ObservMeConfig>, path: string[], value: string | undefined) {
|
|
790
|
+
if (value === undefined) return;
|
|
607
791
|
const parsed = parseBooleanEnv(value);
|
|
608
|
-
|
|
609
|
-
setPathValue(target as Record<string, unknown>, path, parsed);
|
|
792
|
+
setPathValue(target as Record<string, unknown>, path, parsed ?? value);
|
|
610
793
|
}
|
|
611
794
|
|
|
612
795
|
function setPathValue(target: Record<string, unknown>, path: string[], value: unknown) {
|
|
@@ -621,9 +804,8 @@ function setPathValue(target: Record<string, unknown>, path: string[], value: un
|
|
|
621
804
|
current[path.at(-1)!] = value;
|
|
622
805
|
}
|
|
623
806
|
|
|
624
|
-
function parseBooleanEnv(value: string
|
|
625
|
-
|
|
626
|
-
const normalized = value.toLowerCase();
|
|
807
|
+
function parseBooleanEnv(value: string): boolean | undefined {
|
|
808
|
+
const normalized = value.trim().toLowerCase();
|
|
627
809
|
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
628
810
|
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
629
811
|
return undefined;
|
|
@@ -636,7 +818,3 @@ function isPlainObject(value: unknown): value is ConfigObject {
|
|
|
636
818
|
function isMissingFileError(error: unknown): boolean {
|
|
637
819
|
return isPlainObject(error) && error.code === "ENOENT";
|
|
638
820
|
}
|
|
639
|
-
|
|
640
|
-
function formatError(error: unknown): string {
|
|
641
|
-
return error instanceof Error ? error.message : String(error);
|
|
642
|
-
}
|