@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +24 -8
  3. package/docs/agent-subagent-observability-requirements.md +4 -3
  4. package/docs/compatibility-matrix.md +14 -3
  5. package/docs/configuration.md +2 -2
  6. package/docs/extension-integration.md +20 -13
  7. package/docs/reference/03-pi-event-and-session-model.md +6 -6
  8. package/docs/reference/04-telemetry-semantic-conventions.md +1 -0
  9. package/docs/reference/06-security-privacy-redaction.md +13 -6
  10. package/docs/reference/08-query-grafana-integration.md +30 -7
  11. package/docs/reference/11-deployment-runbooks.md +21 -0
  12. package/docs/reference/12-configuration-reference.md +30 -4
  13. package/docs/review-validation.md +10 -0
  14. package/examples/integrations/subagent-runner.ts +8 -5
  15. package/examples/observme.yaml +2 -2
  16. package/package.json +12 -4
  17. package/src/commands/obs-agents.ts +17 -12
  18. package/src/commands/obs-backfill.ts +2 -2
  19. package/src/commands/obs-command-support.ts +2 -1
  20. package/src/commands/obs-cost.ts +15 -7
  21. package/src/commands/obs-health.ts +22 -2
  22. package/src/commands/obs-loki-summary.ts +4 -8
  23. package/src/commands/obs-session.ts +35 -28
  24. package/src/commands/obs-status.ts +56 -13
  25. package/src/commands/obs-tools.ts +19 -8
  26. package/src/commands/obs-trace.ts +9 -0
  27. package/src/commands/obs.ts +6 -1
  28. package/src/config/bootstrap-project-config.ts +49 -32
  29. package/src/config/defaults.ts +0 -2
  30. package/src/config/load-config.ts +270 -92
  31. package/src/config/project-paths.ts +318 -6
  32. package/src/config/query-limits.ts +10 -0
  33. package/src/config/schema.ts +9 -8
  34. package/src/config/transport-security.ts +107 -0
  35. package/src/config/validate.ts +68 -11
  36. package/src/extension.ts +2 -12
  37. package/src/integration.ts +6 -2
  38. package/src/otel/logs.ts +24 -13
  39. package/src/otel/metrics.ts +24 -13
  40. package/src/otel/otlp-endpoint.ts +41 -2
  41. package/src/otel/otlp-http-options.ts +11 -0
  42. package/src/otel/sdk.ts +155 -9
  43. package/src/otel/shutdown.ts +39 -11
  44. package/src/otel/traces.ts +34 -16
  45. package/src/pi/agent-tree-tracker.ts +14 -1
  46. package/src/pi/compatibility.ts +30 -0
  47. package/src/pi/event-handlers/agent-turn.ts +16 -9
  48. package/src/pi/event-handlers/lifecycle.ts +207 -75
  49. package/src/pi/event-handlers/llm.ts +17 -9
  50. package/src/pi/event-handlers/session-events.ts +53 -19
  51. package/src/pi/event-handlers/tool-bash.ts +21 -27
  52. package/src/pi/handler-internals.ts +16 -26
  53. package/src/pi/handler-runtime.ts +142 -55
  54. package/src/pi/handler-types.ts +30 -15
  55. package/src/pi/handlers.ts +12 -1
  56. package/src/pi/integration-api.ts +27 -15
  57. package/src/pi/session-correlation.ts +167 -0
  58. package/src/pi/subagent-spawn.ts +164 -31
  59. package/src/privacy/redact.ts +574 -68
  60. package/src/privacy/secret-patterns.ts +6 -1
  61. package/src/query/grafana-readiness.ts +18 -2
  62. package/src/query/grafana-transport.ts +150 -27
  63. package/src/query/grafana-url.ts +36 -0
  64. package/src/query/grafana.ts +4 -136
  65. package/src/query/loki.ts +2 -4
  66. package/src/query/prometheus.ts +8 -10
  67. package/src/query/tempo.ts +2 -4
  68. package/src/query/trace-link.ts +186 -0
  69. package/src/safety/display-bounds.ts +84 -0
  70. package/src/semconv/metrics.ts +1 -0
  71. 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 { resolveProjectLocalFilePath } from "./project-paths.ts";
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 { ensureValidObservMeConfig, ensureValidObservMeConfigWithDiagnostics } from "./validate.ts";
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 = "loaded" | "missing" | "skipped_untrusted";
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 globalConfig = await readConfigFile(resolveGlobalConfigPath(options), options);
126
+ const globalSource = classifyConfigSource(
127
+ await readConfigSource(resolveGlobalConfigPath(options), options),
128
+ );
90
129
  const projectTrusted = await resolveProjectTrust(options);
91
- const projectConfig = projectTrusted ? await readProjectConfigFile(options) : undefined;
92
- const envFile = await readTrustedProjectEnvFile(projectTrusted, options);
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 effectiveEnvironment = mergeEnvironment(envFile, environment);
95
- const envFileConfig = envFile ? envToConfig(envFile) : undefined;
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
- globalConfig,
100
- projectConfig,
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: Boolean(projectConfig),
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
- globalConfig,
113
- projectConfig,
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: validation.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
- setNumberForValidation(
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 globalConfig?: DeepPartial<ObservMeConfig>;
195
- readonly projectConfig?: DeepPartial<ObservMeConfig>;
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.globalConfig,
212
- projectConfig: options.projectConfig,
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
- const text = await readOptionalText(path, options.readText ?? defaultReadConfigText);
262
- if (!text || text.trim() === "") return undefined;
263
- return parseObservMeConfigText(text);
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 readProjectConfigFile(options: LoadSessionConfigOptions): Promise<DeepPartial<ObservMeConfig> | undefined> {
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
- return await readConfigFile(resolveProjectConfigPath(options), options);
274
- } catch (error) {
275
- warnIgnoredProjectPath(options, "config", error);
276
- return undefined;
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 readTrustedProjectEnvFile(
425
+ async function readTrustedProjectEnvSource(
281
426
  projectTrusted: boolean,
282
427
  options: LoadSessionConfigOptions,
283
- ): Promise<NodeJS.ProcessEnv | undefined> {
284
- if (!projectTrusted || options.loadEnvFile === false) return undefined;
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
- return await readEnvFile(resolveProjectEnvFilePath(options), options);
288
- } catch (error) {
289
- warnIgnoredProjectPath(options, "env", error);
290
- return undefined;
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 readEnvFile(path: string, options: LoadConfigOptions): Promise<NodeJS.ProcessEnv | undefined> {
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 (!text || text.trim() === "") return undefined;
298
- return parseEnvFileText(text);
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 (isMissingFileError(error)) return undefined;
301
- options.logger?.warn?.(`ObservMe env file ${path} was ignored: ${formatError(error)}`);
302
- return undefined;
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 resolveProjectConfigPath(options: LoadSessionConfigOptions): string {
324
- return resolveProjectLocalFilePath({
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 resolveProjectEnvFilePath(options: LoadSessionConfigOptions): string {
335
- return resolveProjectLocalFilePath({
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 || value === "") return;
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 || value === "") return;
595
- const parsed = Number(value);
596
- if (!Number.isFinite(parsed)) return;
597
- setPathValue(target as Record<string, unknown>, path, parsed);
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
- if (parsed === undefined) return;
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 | undefined): boolean | undefined {
625
- if (value === undefined || value === "") return undefined;
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
- }