@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
|
@@ -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,13 +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);
|
|
198
|
+
setNumber(config, ["metrics", "activeAgentLeaseDurationMillis"], env.OBSERVME_ACTIVE_AGENT_LEASE_DURATION_MS);
|
|
150
199
|
setAuthorizationHeader(config, env.OBSERVME_OTLP_TOKEN);
|
|
151
200
|
setNumber(config, ["workflow", "maxDepthWarning"], env.OBSERVME_WORKFLOW_MAX_DEPTH_WARNING);
|
|
152
201
|
setNumber(config, ["workflow", "maxFanoutWarning"], env.OBSERVME_WORKFLOW_MAX_FANOUT_WARNING);
|
|
@@ -186,43 +235,38 @@ export function envToConfig(env: NodeJS.ProcessEnv = process.env): DeepPartial<O
|
|
|
186
235
|
}
|
|
187
236
|
|
|
188
237
|
function createSessionConfigDiagnostics(options: {
|
|
189
|
-
readonly
|
|
190
|
-
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>;
|
|
191
242
|
readonly projectTrusted: boolean;
|
|
192
243
|
readonly envFileConfig?: DeepPartial<ObservMeConfig>;
|
|
193
244
|
readonly environmentConfig: DeepPartial<ObservMeConfig>;
|
|
194
245
|
readonly runtimeOptions?: DeepPartial<ObservMeConfig>;
|
|
195
246
|
readonly rejection?: ConfigRejectionDiagnostic;
|
|
196
247
|
}): SessionConfigDiagnostics {
|
|
197
|
-
const globalConfigLoaded = options.globalConfig !== undefined;
|
|
198
|
-
const projectConfigStatus = resolveSessionConfigProjectStatus(options.projectTrusted, options.projectConfig);
|
|
199
248
|
const environmentOverrides = hasConfigLayer(options.envFileConfig) || hasConfigLayer(options.environmentConfig);
|
|
200
249
|
const runtimeOptionsApplied = hasConfigLayer(options.runtimeOptions);
|
|
201
250
|
|
|
202
251
|
return {
|
|
203
252
|
projectTrusted: options.projectTrusted,
|
|
204
|
-
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,
|
|
205
257
|
effectiveSource: resolveSessionConfigEffectiveSource({
|
|
206
|
-
globalConfig: options.
|
|
207
|
-
projectConfig: options.
|
|
258
|
+
globalConfig: options.globalSource.value,
|
|
259
|
+
projectConfig: options.projectSource.value,
|
|
208
260
|
environmentOverrides,
|
|
209
261
|
runtimeOptionsApplied,
|
|
210
262
|
}),
|
|
211
|
-
globalConfigLoaded,
|
|
263
|
+
globalConfigLoaded: options.globalSource.status === "loaded",
|
|
212
264
|
environmentOverrides,
|
|
213
265
|
runtimeOptionsApplied,
|
|
214
266
|
rejection: options.rejection,
|
|
215
267
|
};
|
|
216
268
|
}
|
|
217
269
|
|
|
218
|
-
function resolveSessionConfigProjectStatus(
|
|
219
|
-
projectTrusted: boolean,
|
|
220
|
-
projectConfig: DeepPartial<ObservMeConfig> | undefined,
|
|
221
|
-
): SessionConfigProjectStatus {
|
|
222
|
-
if (!projectTrusted) return "skipped_untrusted";
|
|
223
|
-
return projectConfig === undefined ? "missing" : "loaded";
|
|
224
|
-
}
|
|
225
|
-
|
|
226
270
|
function resolveSessionConfigEffectiveSource(options: {
|
|
227
271
|
readonly globalConfig?: DeepPartial<ObservMeConfig>;
|
|
228
272
|
readonly projectConfig?: DeepPartial<ObservMeConfig>;
|
|
@@ -240,6 +284,92 @@ function hasConfigLayer(layer: DeepPartial<ObservMeConfig> | undefined): boolean
|
|
|
240
284
|
return layer !== undefined && Object.keys(layer).length > 0;
|
|
241
285
|
}
|
|
242
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
|
+
|
|
243
373
|
export function parseObservMeConfigText(text: string): DeepPartial<ObservMeConfig> {
|
|
244
374
|
const parsed = text.trimStart().startsWith("{") ? (JSON.parse(text) as ConfigObject) : parseSimpleYaml(text);
|
|
245
375
|
const observmeConfig = parsed.observme;
|
|
@@ -252,49 +382,96 @@ async function readConfigFile(
|
|
|
252
382
|
path: string,
|
|
253
383
|
options: LoadConfigOptions,
|
|
254
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
|
+
|
|
255
399
|
try {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
return
|
|
259
|
-
} catch (error) {
|
|
260
|
-
if (isMissingFileError(error)) return undefined;
|
|
261
|
-
options.logger?.warn?.(`ObservMe config file ${path} was ignored: ${formatError(error)}`);
|
|
262
|
-
return undefined;
|
|
400
|
+
return { status: "loaded", value: parseObservMeConfigText(textResult.value!) };
|
|
401
|
+
} catch {
|
|
402
|
+
return { status: "malformed", issueCode: "config_source_malformed" };
|
|
263
403
|
}
|
|
264
404
|
}
|
|
265
405
|
|
|
266
|
-
async function
|
|
406
|
+
async function readProjectConfigSource(
|
|
407
|
+
projectTrusted: boolean,
|
|
408
|
+
options: LoadSessionConfigOptions,
|
|
409
|
+
): Promise<ConfigSourceResult<DeepPartial<ObservMeConfig>>> {
|
|
410
|
+
if (!projectTrusted) return { status: "missing" };
|
|
411
|
+
|
|
267
412
|
try {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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" };
|
|
272
422
|
}
|
|
273
423
|
}
|
|
274
424
|
|
|
275
|
-
async function
|
|
425
|
+
async function readTrustedProjectEnvSource(
|
|
276
426
|
projectTrusted: boolean,
|
|
277
427
|
options: LoadSessionConfigOptions,
|
|
278
|
-
): Promise<NodeJS.ProcessEnv
|
|
279
|
-
if (!projectTrusted
|
|
428
|
+
): Promise<ConfigSourceResult<NodeJS.ProcessEnv, SessionConfigEnvFileStatus>> {
|
|
429
|
+
if (!projectTrusted) return { status: "skipped_untrusted" };
|
|
430
|
+
if (options.loadEnvFile === false) return { status: "skipped_disabled" };
|
|
280
431
|
|
|
281
432
|
try {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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" };
|
|
286
442
|
}
|
|
287
443
|
}
|
|
288
444
|
|
|
289
|
-
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>> {
|
|
290
465
|
try {
|
|
291
466
|
const text = await readOptionalText(path, options.readText ?? defaultReadConfigText);
|
|
292
|
-
if (
|
|
293
|
-
|
|
467
|
+
if (text === undefined) return { status: "missing" };
|
|
468
|
+
if (text.trim() === "") return { status: "malformed", issueCode: "config_source_malformed" };
|
|
469
|
+
return { status: "loaded", value: text };
|
|
294
470
|
} catch (error) {
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
471
|
+
if (isUnsafeProjectPathError(error)) {
|
|
472
|
+
return { status: "rejected", issueCode: "config_source_rejected" };
|
|
473
|
+
}
|
|
474
|
+
return { status: "unreadable", issueCode: "config_source_unreadable" };
|
|
298
475
|
}
|
|
299
476
|
}
|
|
300
477
|
|
|
@@ -315,28 +492,36 @@ function resolveGlobalConfigPath(options: LoadConfigOptions): string {
|
|
|
315
492
|
return options.globalConfigPath ?? join(homedir(), CONFIG_DIR_NAME, "agent", observmeYamlFileName);
|
|
316
493
|
}
|
|
317
494
|
|
|
318
|
-
function
|
|
319
|
-
|
|
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 {
|
|
320
509
|
cwd: options.cwd,
|
|
321
510
|
configDirName: options.configDirName,
|
|
322
511
|
defaultConfigDirName: CONFIG_DIR_NAME,
|
|
323
512
|
fileName: observmeYamlFileName,
|
|
324
513
|
overridePath: options.projectConfigPath,
|
|
325
514
|
inputLabel: "project config path",
|
|
326
|
-
}
|
|
515
|
+
};
|
|
327
516
|
}
|
|
328
517
|
|
|
329
|
-
function
|
|
330
|
-
return
|
|
518
|
+
function createProjectEnvFilePathOptions(options: LoadSessionConfigOptions): ProjectLocalFilePathOptions {
|
|
519
|
+
return {
|
|
331
520
|
cwd: options.cwd,
|
|
332
521
|
fileName: defaultEnvFileName,
|
|
333
522
|
overridePath: options.envFilePath ?? defaultEnvFileName,
|
|
334
523
|
inputLabel: "project env path",
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function warnIgnoredProjectPath(options: LoadSessionConfigOptions, source: "config" | "env", error: unknown) {
|
|
339
|
-
options.logger?.warn?.(`ObservMe project ${source} file was ignored: ${formatError(error)}`);
|
|
524
|
+
};
|
|
340
525
|
}
|
|
341
526
|
|
|
342
527
|
async function resolveProjectTrust(options: LoadSessionConfigOptions): Promise<boolean> {
|
|
@@ -412,9 +597,17 @@ function parseEnvValue(valueText: string): string {
|
|
|
412
597
|
function parseQuotedEnvValue(valueText: string): string {
|
|
413
598
|
const closingIndex = findClosingEnvQuote(valueText);
|
|
414
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
|
+
}
|
|
415
603
|
return unquoteEnvValue(valueText.slice(0, closingIndex + 1));
|
|
416
604
|
}
|
|
417
605
|
|
|
606
|
+
function isValidQuotedEnvValueSuffix(suffix: string): boolean {
|
|
607
|
+
if (suffix.trim() === "") return true;
|
|
608
|
+
return /^\s+#/u.test(suffix);
|
|
609
|
+
}
|
|
610
|
+
|
|
418
611
|
function findClosingEnvQuote(valueText: string): number {
|
|
419
612
|
const quote = valueText[0];
|
|
420
613
|
|
|
@@ -581,21 +774,22 @@ function setAuthorizationHeader(config: DeepPartial<ObservMeConfig>, token: stri
|
|
|
581
774
|
}
|
|
582
775
|
|
|
583
776
|
function setString(target: DeepPartial<ObservMeConfig>, path: string[], value: string | undefined) {
|
|
584
|
-
if (value === undefined
|
|
777
|
+
if (value === undefined) return;
|
|
585
778
|
setPathValue(target as Record<string, unknown>, path, value);
|
|
586
779
|
}
|
|
587
780
|
|
|
588
781
|
function setNumber(target: DeepPartial<ObservMeConfig>, path: string[], value: string | undefined) {
|
|
589
|
-
if (value === undefined
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
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);
|
|
593
787
|
}
|
|
594
788
|
|
|
595
789
|
function setBoolean(target: DeepPartial<ObservMeConfig>, path: string[], value: string | undefined) {
|
|
790
|
+
if (value === undefined) return;
|
|
596
791
|
const parsed = parseBooleanEnv(value);
|
|
597
|
-
|
|
598
|
-
setPathValue(target as Record<string, unknown>, path, parsed);
|
|
792
|
+
setPathValue(target as Record<string, unknown>, path, parsed ?? value);
|
|
599
793
|
}
|
|
600
794
|
|
|
601
795
|
function setPathValue(target: Record<string, unknown>, path: string[], value: unknown) {
|
|
@@ -610,9 +804,8 @@ function setPathValue(target: Record<string, unknown>, path: string[], value: un
|
|
|
610
804
|
current[path.at(-1)!] = value;
|
|
611
805
|
}
|
|
612
806
|
|
|
613
|
-
function parseBooleanEnv(value: string
|
|
614
|
-
|
|
615
|
-
const normalized = value.toLowerCase();
|
|
807
|
+
function parseBooleanEnv(value: string): boolean | undefined {
|
|
808
|
+
const normalized = value.trim().toLowerCase();
|
|
616
809
|
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
617
810
|
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
618
811
|
return undefined;
|
|
@@ -625,7 +818,3 @@ function isPlainObject(value: unknown): value is ConfigObject {
|
|
|
625
818
|
function isMissingFileError(error: unknown): boolean {
|
|
626
819
|
return isPlainObject(error) && error.code === "ENOENT";
|
|
627
820
|
}
|
|
628
|
-
|
|
629
|
-
function formatError(error: unknown): string {
|
|
630
|
-
return error instanceof Error ? error.message : String(error);
|
|
631
|
-
}
|