@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.
Files changed (83) hide show
  1. package/.env.example +4 -0
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +36 -11
  4. package/dashboards/observme-agent-node-graphs.json +5 -5
  5. package/dashboards/observme-agents.json +169 -4
  6. package/dashboards/observme-alerts.yaml +16 -3
  7. package/dashboards/observme-overview.json +6 -6
  8. package/dashboards/observme-trace-journey.json +4 -4
  9. package/docs/agent-subagent-observability-requirements.md +19 -10
  10. package/docs/compatibility-matrix.md +21 -4
  11. package/docs/configuration.md +7 -2
  12. package/docs/extension-integration.md +20 -13
  13. package/docs/reference/03-pi-event-and-session-model.md +6 -6
  14. package/docs/reference/04-telemetry-semantic-conventions.md +39 -1
  15. package/docs/reference/05-otel-pipeline-and-collector.md +23 -10
  16. package/docs/reference/06-security-privacy-redaction.md +13 -6
  17. package/docs/reference/08-query-grafana-integration.md +30 -7
  18. package/docs/reference/09-dashboards-alerts-slos.md +132 -3
  19. package/docs/reference/10-testing-release-operations.md +44 -14
  20. package/docs/reference/11-deployment-runbooks.md +82 -5
  21. package/docs/reference/12-configuration-reference.md +48 -5
  22. package/docs/review-validation.md +17 -0
  23. package/examples/README.md +7 -2
  24. package/examples/collector.yaml +8 -8
  25. package/examples/integrations/subagent-runner.ts +8 -5
  26. package/examples/observme.yaml +3 -2
  27. package/package.json +14 -4
  28. package/src/commands/obs-agents.ts +17 -12
  29. package/src/commands/obs-backfill.ts +2 -2
  30. package/src/commands/obs-command-support.ts +2 -1
  31. package/src/commands/obs-cost.ts +15 -7
  32. package/src/commands/obs-health.ts +22 -2
  33. package/src/commands/obs-loki-summary.ts +4 -8
  34. package/src/commands/obs-session.ts +35 -28
  35. package/src/commands/obs-status.ts +56 -13
  36. package/src/commands/obs-tools.ts +19 -8
  37. package/src/commands/obs-trace.ts +9 -0
  38. package/src/commands/obs.ts +6 -1
  39. package/src/config/bootstrap-project-config.ts +50 -32
  40. package/src/config/defaults.ts +1 -2
  41. package/src/config/load-config.ts +270 -81
  42. package/src/config/project-paths.ts +318 -6
  43. package/src/config/query-limits.ts +10 -0
  44. package/src/config/schema.ts +18 -8
  45. package/src/config/transport-security.ts +107 -0
  46. package/src/config/validate.ts +88 -12
  47. package/src/extension.ts +2 -12
  48. package/src/integration.ts +6 -2
  49. package/src/otel/logs.ts +24 -13
  50. package/src/otel/metrics.ts +24 -13
  51. package/src/otel/otlp-endpoint.ts +41 -2
  52. package/src/otel/otlp-http-options.ts +11 -0
  53. package/src/otel/sdk.ts +155 -9
  54. package/src/otel/shutdown.ts +39 -11
  55. package/src/otel/traces.ts +34 -16
  56. package/src/pi/active-agent-lease.ts +101 -0
  57. package/src/pi/agent-tree-tracker.ts +14 -1
  58. package/src/pi/compatibility.ts +121 -0
  59. package/src/pi/event-handlers/agent-turn.ts +16 -9
  60. package/src/pi/event-handlers/lifecycle.ts +312 -106
  61. package/src/pi/event-handlers/llm.ts +17 -9
  62. package/src/pi/event-handlers/session-events.ts +53 -19
  63. package/src/pi/event-handlers/tool-bash.ts +21 -27
  64. package/src/pi/handler-internals.ts +16 -26
  65. package/src/pi/handler-runtime.ts +159 -54
  66. package/src/pi/handler-types.ts +40 -17
  67. package/src/pi/handlers.ts +12 -1
  68. package/src/pi/integration-api.ts +27 -15
  69. package/src/pi/session-correlation.ts +167 -0
  70. package/src/pi/subagent-spawn.ts +164 -31
  71. package/src/privacy/redact.ts +574 -68
  72. package/src/privacy/secret-patterns.ts +6 -1
  73. package/src/query/grafana-readiness.ts +18 -2
  74. package/src/query/grafana-transport.ts +150 -27
  75. package/src/query/grafana-url.ts +36 -0
  76. package/src/query/grafana.ts +4 -136
  77. package/src/query/loki.ts +2 -4
  78. package/src/query/prometheus.ts +8 -10
  79. package/src/query/tempo.ts +2 -4
  80. package/src/query/trace-link.ts +186 -0
  81. package/src/safety/display-bounds.ts +84 -0
  82. package/src/semconv/metrics.ts +7 -0
  83. 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,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 globalConfig?: DeepPartial<ObservMeConfig>;
190
- 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>;
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.globalConfig,
207
- projectConfig: options.projectConfig,
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
- const text = await readOptionalText(path, options.readText ?? defaultReadConfigText);
257
- if (!text || text.trim() === "") return undefined;
258
- return parseObservMeConfigText(text);
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 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
+
267
412
  try {
268
- return await readConfigFile(resolveProjectConfigPath(options), options);
269
- } catch (error) {
270
- warnIgnoredProjectPath(options, "config", error);
271
- 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" };
272
422
  }
273
423
  }
274
424
 
275
- async function readTrustedProjectEnvFile(
425
+ async function readTrustedProjectEnvSource(
276
426
  projectTrusted: boolean,
277
427
  options: LoadSessionConfigOptions,
278
- ): Promise<NodeJS.ProcessEnv | undefined> {
279
- 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" };
280
431
 
281
432
  try {
282
- return await readEnvFile(resolveProjectEnvFilePath(options), options);
283
- } catch (error) {
284
- warnIgnoredProjectPath(options, "env", error);
285
- 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" };
286
442
  }
287
443
  }
288
444
 
289
- 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>> {
290
465
  try {
291
466
  const text = await readOptionalText(path, options.readText ?? defaultReadConfigText);
292
- if (!text || text.trim() === "") return undefined;
293
- 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 };
294
470
  } catch (error) {
295
- if (isMissingFileError(error)) return undefined;
296
- options.logger?.warn?.(`ObservMe env file ${path} was ignored: ${formatError(error)}`);
297
- return undefined;
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 resolveProjectConfigPath(options: LoadSessionConfigOptions): string {
319
- 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 {
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 resolveProjectEnvFilePath(options: LoadSessionConfigOptions): string {
330
- return resolveProjectLocalFilePath({
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 || value === "") return;
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 || value === "") return;
590
- const parsed = Number(value);
591
- if (!Number.isFinite(parsed)) return;
592
- setPathValue(target as Record<string, unknown>, path, parsed);
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
- if (parsed === undefined) return;
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 | undefined): boolean | undefined {
614
- if (value === undefined || value === "") return undefined;
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
- }