@senad-d/observme 0.1.0
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 +57 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/ObservMe-Production-Docs/00-README.md +79 -0
- package/ObservMe-Production-Docs/01-requirements-and-scope.md +207 -0
- package/ObservMe-Production-Docs/02-reference-architecture.md +306 -0
- package/ObservMe-Production-Docs/03-pi-event-and-session-model.md +266 -0
- package/ObservMe-Production-Docs/04-telemetry-semantic-conventions.md +722 -0
- package/ObservMe-Production-Docs/05-otel-pipeline-and-collector.md +355 -0
- package/ObservMe-Production-Docs/06-security-privacy-redaction.md +294 -0
- package/ObservMe-Production-Docs/07-extension-implementation-blueprint.md +447 -0
- package/ObservMe-Production-Docs/08-query-grafana-integration.md +276 -0
- package/ObservMe-Production-Docs/09-dashboards-alerts-slos.md +640 -0
- package/ObservMe-Production-Docs/10-testing-release-operations.md +319 -0
- package/ObservMe-Production-Docs/11-deployment-runbooks.md +203 -0
- package/ObservMe-Production-Docs/12-configuration-reference.md +337 -0
- package/ObservMe-Production-Docs/13-source-notes.md +33 -0
- package/ObservMe-Production-Docs/pi-session-format.md +427 -0
- package/README.md +356 -0
- package/SECURITY.md +45 -0
- package/dashboards/observme-agent-node-graphs.json +250 -0
- package/dashboards/observme-agents.json +1880 -0
- package/dashboards/observme-alerts.yaml +113 -0
- package/dashboards/observme-branches-compactions.json +1042 -0
- package/dashboards/observme-cost.json +1254 -0
- package/dashboards/observme-errors.json +1659 -0
- package/dashboards/observme-export-health.json +1802 -0
- package/dashboards/observme-latency.json +1494 -0
- package/dashboards/observme-llm-conversations.json +730 -0
- package/dashboards/observme-logs-llm.json +644 -0
- package/dashboards/observme-models.json +933 -0
- package/dashboards/observme-overview.json +2129 -0
- package/dashboards/observme-slo-health.json +737 -0
- package/dashboards/observme-slos.yaml +56 -0
- package/dashboards/observme-tools.json +902 -0
- package/dashboards/observme-trace-journey.json +1793 -0
- package/docs/STRUCTURE.md +49 -0
- package/docs/agent-subagent-observability-requirements.md +997 -0
- package/docs/compatibility-matrix.md +39 -0
- package/docs/configuration-tui-design-standard.md +767 -0
- package/docs/configuration.md +38 -0
- package/docs/review-validation.md +115 -0
- package/docs/validation-flow.md +117 -0
- package/examples/collector.yaml +123 -0
- package/examples/observme.yaml +131 -0
- package/img/demo.gif +0 -0
- package/img/icon.svg +47 -0
- package/package.json +103 -0
- package/src/commands/obs-agents-runtime.ts +150 -0
- package/src/commands/obs-agents.ts +491 -0
- package/src/commands/obs-args.ts +63 -0
- package/src/commands/obs-backfill.ts +1334 -0
- package/src/commands/obs-command-support.ts +43 -0
- package/src/commands/obs-cost.ts +228 -0
- package/src/commands/obs-diagnostics.ts +22 -0
- package/src/commands/obs-errors.ts +156 -0
- package/src/commands/obs-health.ts +301 -0
- package/src/commands/obs-link.ts +90 -0
- package/src/commands/obs-logs.ts +194 -0
- package/src/commands/obs-loki-summary.ts +184 -0
- package/src/commands/obs-session.ts +259 -0
- package/src/commands/obs-status.ts +359 -0
- package/src/commands/obs-tools.ts +274 -0
- package/src/commands/obs-trace.ts +411 -0
- package/src/commands/obs.ts +211 -0
- package/src/config/bootstrap-project-config.ts +300 -0
- package/src/config/defaults.ts +143 -0
- package/src/config/load-config.ts +631 -0
- package/src/config/project-paths.ts +61 -0
- package/src/config/schema.ts +405 -0
- package/src/config/validate.ts +456 -0
- package/src/constants.ts +4 -0
- package/src/diagnostics/sanitize.ts +6 -0
- package/src/extension.ts +38 -0
- package/src/otel/logs.ts +160 -0
- package/src/otel/metrics.ts +165 -0
- package/src/otel/otlp-endpoint.ts +10 -0
- package/src/otel/sdk.ts +114 -0
- package/src/otel/shutdown.ts +102 -0
- package/src/otel/traces.ts +166 -0
- package/src/pi/agent-lineage.ts +378 -0
- package/src/pi/agent-tree-tracker.ts +258 -0
- package/src/pi/event-handlers/agent-turn.ts +155 -0
- package/src/pi/event-handlers/lifecycle.ts +642 -0
- package/src/pi/event-handlers/llm.ts +115 -0
- package/src/pi/event-handlers/session-events.ts +159 -0
- package/src/pi/event-handlers/tool-bash.ts +275 -0
- package/src/pi/handler-internals.ts +2154 -0
- package/src/pi/handler-runtime.ts +633 -0
- package/src/pi/handler-types.ts +261 -0
- package/src/pi/handlers.ts +75 -0
- package/src/pi/subagent-spawn.ts +975 -0
- package/src/pi/subagent-types.ts +29 -0
- package/src/privacy/content-capture.ts +104 -0
- package/src/privacy/hash.ts +93 -0
- package/src/privacy/redact.ts +619 -0
- package/src/privacy/secret-patterns.ts +185 -0
- package/src/privacy/truncate.ts +69 -0
- package/src/query/grafana-readiness.ts +164 -0
- package/src/query/grafana-transport.ts +481 -0
- package/src/query/grafana.ts +371 -0
- package/src/query/loki.ts +332 -0
- package/src/query/prometheus.ts +388 -0
- package/src/query/tempo.ts +332 -0
- package/src/safety/sensitive-input.ts +208 -0
- package/src/semconv/attributes.ts +279 -0
- package/src/semconv/metrics.ts +146 -0
- package/src/semconv/spans.ts +19 -0
- package/src/semconv/values.ts +13 -0
- package/src/util/bounded-map.ts +97 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { defaultObservMeConfig } from "./defaults.ts";
|
|
6
|
+
import { resolveProjectLocalFilePath } from "./project-paths.ts";
|
|
7
|
+
import type { ConfigLogSink, ConfigRejectionDiagnostic } from "./validate.ts";
|
|
8
|
+
import { ensureValidObservMeConfig, ensureValidObservMeConfigWithDiagnostics } from "./validate.ts";
|
|
9
|
+
import type { ObservMeConfig } from "./schema.ts";
|
|
10
|
+
import { registerTenantSaltEnvironment } from "../privacy/hash.ts";
|
|
11
|
+
import { RESOURCE_ATTRIBUTES } from "../semconv/attributes.ts";
|
|
12
|
+
|
|
13
|
+
export type DeepPartial<T> = {
|
|
14
|
+
[K in keyof T]?: T[K] extends Array<infer U>
|
|
15
|
+
? Array<DeepPartial<U>>
|
|
16
|
+
: T[K] extends Record<string, unknown>
|
|
17
|
+
? DeepPartial<T[K]>
|
|
18
|
+
: T[K];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type ConfigValue = null | boolean | number | string | ConfigObject | ConfigValue[];
|
|
22
|
+
type ConfigObject = { [key: string]: ConfigValue };
|
|
23
|
+
|
|
24
|
+
type ReadConfigText = (path: string) => Promise<string | undefined>;
|
|
25
|
+
|
|
26
|
+
export interface ProjectTrustContext {
|
|
27
|
+
isProjectTrusted?: () => boolean | Promise<boolean>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LoadConfigOptions {
|
|
31
|
+
env?: NodeJS.ProcessEnv;
|
|
32
|
+
runtimeOptions?: DeepPartial<ObservMeConfig>;
|
|
33
|
+
globalConfigPath?: string;
|
|
34
|
+
readText?: ReadConfigText;
|
|
35
|
+
logger?: ConfigLogSink;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LoadSessionConfigOptions extends LoadConfigOptions {
|
|
39
|
+
ctx?: ProjectTrustContext;
|
|
40
|
+
cwd?: string;
|
|
41
|
+
configDirName?: string;
|
|
42
|
+
projectConfigPath?: string;
|
|
43
|
+
envFilePath?: string;
|
|
44
|
+
loadEnvFile?: boolean;
|
|
45
|
+
isProjectTrusted?: boolean | (() => boolean | Promise<boolean>);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type SessionConfigProjectStatus = "loaded" | "missing" | "skipped_untrusted";
|
|
49
|
+
export type SessionConfigEffectiveSource = "runtime_options" | "environment" | "trusted_project" | "global" | "defaults";
|
|
50
|
+
|
|
51
|
+
export interface SessionConfigDiagnostics {
|
|
52
|
+
readonly projectTrusted: boolean;
|
|
53
|
+
readonly projectConfigStatus: SessionConfigProjectStatus;
|
|
54
|
+
readonly effectiveSource: SessionConfigEffectiveSource;
|
|
55
|
+
readonly globalConfigLoaded: boolean;
|
|
56
|
+
readonly environmentOverrides: boolean;
|
|
57
|
+
readonly runtimeOptionsApplied: boolean;
|
|
58
|
+
readonly rejection?: ConfigRejectionDiagnostic;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LoadSessionConfigResult {
|
|
62
|
+
readonly config: ObservMeConfig;
|
|
63
|
+
readonly diagnostics: SessionConfigDiagnostics;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ParsedYamlLine {
|
|
67
|
+
indent: number;
|
|
68
|
+
text: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const defaultEnvFileName = ".env";
|
|
72
|
+
const observmeYamlFileName = "observme.yaml";
|
|
73
|
+
const yamlIndentPattern = /^ */u;
|
|
74
|
+
|
|
75
|
+
export async function loadFactoryConfig(options: LoadConfigOptions = {}): Promise<ObservMeConfig> {
|
|
76
|
+
const globalConfig = await readConfigFile(resolveGlobalConfigPath(options), options);
|
|
77
|
+
const environment = options.env ?? process.env;
|
|
78
|
+
const config = mergeConfigLayers([defaultObservMeConfig, globalConfig, envToConfig(options.env), options.runtimeOptions]);
|
|
79
|
+
const validConfig = ensureValidObservMeConfig(config, { env: options.env, logger: options.logger });
|
|
80
|
+
|
|
81
|
+
return registerTenantSaltEnvironment(validConfig, environment);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function loadSessionConfig(options: LoadSessionConfigOptions = {}): Promise<ObservMeConfig> {
|
|
85
|
+
return (await loadSessionConfigWithDiagnostics(options)).config;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function loadSessionConfigWithDiagnostics(options: LoadSessionConfigOptions = {}): Promise<LoadSessionConfigResult> {
|
|
89
|
+
const globalConfig = await readConfigFile(resolveGlobalConfigPath(options), options);
|
|
90
|
+
const projectTrusted = await resolveProjectTrust(options);
|
|
91
|
+
const projectConfig = projectTrusted ? await readProjectConfigFile(options) : undefined;
|
|
92
|
+
const envFile = await readTrustedProjectEnvFile(projectTrusted, options);
|
|
93
|
+
const environment = options.env ?? process.env;
|
|
94
|
+
const effectiveEnvironment = mergeEnvironment(envFile, environment);
|
|
95
|
+
const envFileConfig = envFile ? envToConfig(envFile) : undefined;
|
|
96
|
+
const environmentConfig = envToConfig(environment);
|
|
97
|
+
const config = mergeConfigLayers([
|
|
98
|
+
defaultObservMeConfig,
|
|
99
|
+
globalConfig,
|
|
100
|
+
projectConfig,
|
|
101
|
+
envFileConfig,
|
|
102
|
+
environmentConfig,
|
|
103
|
+
options.runtimeOptions,
|
|
104
|
+
]);
|
|
105
|
+
const validation = ensureValidObservMeConfigWithDiagnostics(config, {
|
|
106
|
+
env: effectiveEnvironment,
|
|
107
|
+
isProjectTrusted: projectTrusted,
|
|
108
|
+
projectConfigWasRead: Boolean(projectConfig),
|
|
109
|
+
logger: options.logger,
|
|
110
|
+
});
|
|
111
|
+
const diagnostics = createSessionConfigDiagnostics({
|
|
112
|
+
globalConfig,
|
|
113
|
+
projectConfig,
|
|
114
|
+
projectTrusted,
|
|
115
|
+
envFileConfig,
|
|
116
|
+
environmentConfig,
|
|
117
|
+
runtimeOptions: options.runtimeOptions,
|
|
118
|
+
rejection: validation.rejection,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
config: registerTenantSaltEnvironment(validation.config, effectiveEnvironment),
|
|
123
|
+
diagnostics,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function mergeConfigLayers(layers: Array<DeepPartial<ObservMeConfig> | undefined>): ObservMeConfig {
|
|
128
|
+
let merged = cloneConfig(defaultObservMeConfig) as unknown as ConfigObject;
|
|
129
|
+
|
|
130
|
+
for (const layer of layers.slice(1)) {
|
|
131
|
+
if (layer) merged = deepMergeObjects(merged, layer as ConfigObject);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return merged as unknown as ObservMeConfig;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function envToConfig(env: NodeJS.ProcessEnv = process.env): DeepPartial<ObservMeConfig> {
|
|
138
|
+
const config: DeepPartial<ObservMeConfig> = {};
|
|
139
|
+
|
|
140
|
+
setBoolean(config, ["enabled"], env.OBSERVME_ENABLED);
|
|
141
|
+
setString(config, ["environment"], env.OBSERVME_ENVIRONMENT);
|
|
142
|
+
setString(config, ["tenant"], env.OBSERVME_TENANT);
|
|
143
|
+
setBoolean(config, ["replayOnStart"], env.OBSERVME_REPLAY_ON_START);
|
|
144
|
+
setString(config, ["otlp", "endpoint"], env.OBSERVME_OTLP_ENDPOINT);
|
|
145
|
+
setString(config, ["otlp", "protocol"], env.OBSERVME_OTLP_PROTOCOL);
|
|
146
|
+
setString(config, ["otlp", "signalEndpoints", "traces"], env.OBSERVME_OTLP_TRACES_ENDPOINT);
|
|
147
|
+
setString(config, ["otlp", "signalEndpoints", "metrics"], env.OBSERVME_OTLP_METRICS_ENDPOINT);
|
|
148
|
+
setString(config, ["otlp", "signalEndpoints", "logs"], env.OBSERVME_OTLP_LOGS_ENDPOINT);
|
|
149
|
+
setNumber(config, ["otlp", "timeoutMs"], env.OBSERVME_OTLP_TIMEOUT_MS);
|
|
150
|
+
setAuthorizationHeader(config, env.OBSERVME_OTLP_TOKEN);
|
|
151
|
+
setNumber(config, ["workflow", "maxDepthWarning"], env.OBSERVME_WORKFLOW_MAX_DEPTH_WARNING);
|
|
152
|
+
setNumber(config, ["workflow", "maxFanoutWarning"], env.OBSERVME_WORKFLOW_MAX_FANOUT_WARNING);
|
|
153
|
+
setBoolean(config, ["agent", "propagateTraceContext"], env.OBSERVME_PROPAGATE_TRACE_CONTEXT);
|
|
154
|
+
setBoolean(config, ["agent", "propagateToSubagents"], env.OBSERVME_PROPAGATE_TO_SUBAGENTS);
|
|
155
|
+
setBoolean(config, ["agent", "writeCorrelationEntry"], env.OBSERVME_WRITE_CORRELATION_ENTRY);
|
|
156
|
+
setString(config, ["query", "grafana", "url"], env.OBSERVME_GRAFANA_URL);
|
|
157
|
+
setString(config, ["query", "grafana", "token"], env.OBSERVME_GRAFANA_TOKEN);
|
|
158
|
+
setString(config, ["query", "grafana", "username"], env.OBSERVME_GRAFANA_USERNAME);
|
|
159
|
+
setString(config, ["query", "grafana", "password"], env.OBSERVME_GRAFANA_PASSWORD);
|
|
160
|
+
setString(config, ["query", "grafana", "datasourceUids", "tempo"], env.OBSERVME_GRAFANA_TEMPO_DATASOURCE_UID);
|
|
161
|
+
setString(config, ["query", "grafana", "datasourceUids", "loki"], env.OBSERVME_GRAFANA_LOKI_DATASOURCE_UID);
|
|
162
|
+
setString(config, ["query", "grafana", "datasourceUids", "prometheus"], env.OBSERVME_GRAFANA_PROMETHEUS_DATASOURCE_UID);
|
|
163
|
+
setBoolean(config, ["query", "grafana", "tls", "insecureSkipVerify"], env.OBSERVME_GRAFANA_TLS_INSECURE_SKIP_VERIFY);
|
|
164
|
+
setBoolean(config, ["query", "grafana", "transport", "preferIPv4"], env.OBSERVME_GRAFANA_PREFER_IPV4);
|
|
165
|
+
setBoolean(config, ["capture", "prompts"], env.OBSERVME_CAPTURE_PROMPTS);
|
|
166
|
+
setBoolean(config, ["capture", "responses"], env.OBSERVME_CAPTURE_RESPONSES);
|
|
167
|
+
setBoolean(config, ["capture", "toolArguments"], env.OBSERVME_CAPTURE_TOOL_ARGUMENTS);
|
|
168
|
+
setBoolean(config, ["capture", "toolResults"], env.OBSERVME_CAPTURE_TOOL_RESULTS);
|
|
169
|
+
setBoolean(config, ["capture", "thinking"], env.OBSERVME_CAPTURE_THINKING);
|
|
170
|
+
setBoolean(config, ["capture", "bashCommands"], env.OBSERVME_CAPTURE_BASH_COMMANDS);
|
|
171
|
+
setBoolean(config, ["capture", "bashOutput"], env.OBSERVME_CAPTURE_BASH_OUTPUT);
|
|
172
|
+
setBoolean(config, ["capture", "filePaths"], env.OBSERVME_CAPTURE_FILE_PATHS);
|
|
173
|
+
setBoolean(config, ["privacy", "redactionEnabled"], env.OBSERVME_REDACTION_ENABLED);
|
|
174
|
+
setBoolean(config, ["privacy", "allowUnsafeCapture"], env.OBSERVME_ALLOW_UNSAFE_CAPTURE);
|
|
175
|
+
setBoolean(config, ["privacy", "allowInsecureTransport"], env.OBSERVME_ALLOW_INSECURE_TRANSPORT);
|
|
176
|
+
|
|
177
|
+
if (env.OBSERVME_TENANT) {
|
|
178
|
+
setString(config, ["resource", "attributes", RESOURCE_ATTRIBUTES.OBSERVME_TENANT_ID], env.OBSERVME_TENANT);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (env.OBSERVME_ENVIRONMENT) {
|
|
182
|
+
setString(config, ["resource", "attributes", RESOURCE_ATTRIBUTES.DEPLOYMENT_ENVIRONMENT_NAME], env.OBSERVME_ENVIRONMENT);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return config;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function createSessionConfigDiagnostics(options: {
|
|
189
|
+
readonly globalConfig?: DeepPartial<ObservMeConfig>;
|
|
190
|
+
readonly projectConfig?: DeepPartial<ObservMeConfig>;
|
|
191
|
+
readonly projectTrusted: boolean;
|
|
192
|
+
readonly envFileConfig?: DeepPartial<ObservMeConfig>;
|
|
193
|
+
readonly environmentConfig: DeepPartial<ObservMeConfig>;
|
|
194
|
+
readonly runtimeOptions?: DeepPartial<ObservMeConfig>;
|
|
195
|
+
readonly rejection?: ConfigRejectionDiagnostic;
|
|
196
|
+
}): SessionConfigDiagnostics {
|
|
197
|
+
const globalConfigLoaded = options.globalConfig !== undefined;
|
|
198
|
+
const projectConfigStatus = resolveSessionConfigProjectStatus(options.projectTrusted, options.projectConfig);
|
|
199
|
+
const environmentOverrides = hasConfigLayer(options.envFileConfig) || hasConfigLayer(options.environmentConfig);
|
|
200
|
+
const runtimeOptionsApplied = hasConfigLayer(options.runtimeOptions);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
projectTrusted: options.projectTrusted,
|
|
204
|
+
projectConfigStatus,
|
|
205
|
+
effectiveSource: resolveSessionConfigEffectiveSource({
|
|
206
|
+
globalConfig: options.globalConfig,
|
|
207
|
+
projectConfig: options.projectConfig,
|
|
208
|
+
environmentOverrides,
|
|
209
|
+
runtimeOptionsApplied,
|
|
210
|
+
}),
|
|
211
|
+
globalConfigLoaded,
|
|
212
|
+
environmentOverrides,
|
|
213
|
+
runtimeOptionsApplied,
|
|
214
|
+
rejection: options.rejection,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
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
|
+
function resolveSessionConfigEffectiveSource(options: {
|
|
227
|
+
readonly globalConfig?: DeepPartial<ObservMeConfig>;
|
|
228
|
+
readonly projectConfig?: DeepPartial<ObservMeConfig>;
|
|
229
|
+
readonly environmentOverrides: boolean;
|
|
230
|
+
readonly runtimeOptionsApplied: boolean;
|
|
231
|
+
}): SessionConfigEffectiveSource {
|
|
232
|
+
if (options.runtimeOptionsApplied) return "runtime_options";
|
|
233
|
+
if (options.environmentOverrides) return "environment";
|
|
234
|
+
if (hasConfigLayer(options.projectConfig)) return "trusted_project";
|
|
235
|
+
if (hasConfigLayer(options.globalConfig)) return "global";
|
|
236
|
+
return "defaults";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function hasConfigLayer(layer: DeepPartial<ObservMeConfig> | undefined): boolean {
|
|
240
|
+
return layer !== undefined && Object.keys(layer).length > 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function parseObservMeConfigText(text: string): DeepPartial<ObservMeConfig> {
|
|
244
|
+
const parsed = text.trimStart().startsWith("{") ? (JSON.parse(text) as ConfigObject) : parseSimpleYaml(text);
|
|
245
|
+
const observmeConfig = parsed.observme;
|
|
246
|
+
|
|
247
|
+
if (isPlainObject(observmeConfig)) return observmeConfig as unknown as DeepPartial<ObservMeConfig>;
|
|
248
|
+
return parsed as unknown as DeepPartial<ObservMeConfig>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function readConfigFile(
|
|
252
|
+
path: string,
|
|
253
|
+
options: LoadConfigOptions,
|
|
254
|
+
): Promise<DeepPartial<ObservMeConfig> | undefined> {
|
|
255
|
+
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;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function readProjectConfigFile(options: LoadSessionConfigOptions): Promise<DeepPartial<ObservMeConfig> | undefined> {
|
|
267
|
+
try {
|
|
268
|
+
return await readConfigFile(resolveProjectConfigPath(options), options);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
warnIgnoredProjectPath(options, "config", error);
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function readTrustedProjectEnvFile(
|
|
276
|
+
projectTrusted: boolean,
|
|
277
|
+
options: LoadSessionConfigOptions,
|
|
278
|
+
): Promise<NodeJS.ProcessEnv | undefined> {
|
|
279
|
+
if (!projectTrusted || options.loadEnvFile === false) return undefined;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
return await readEnvFile(resolveProjectEnvFilePath(options), options);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
warnIgnoredProjectPath(options, "env", error);
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function readEnvFile(path: string, options: LoadConfigOptions): Promise<NodeJS.ProcessEnv | undefined> {
|
|
290
|
+
try {
|
|
291
|
+
const text = await readOptionalText(path, options.readText ?? defaultReadConfigText);
|
|
292
|
+
if (!text || text.trim() === "") return undefined;
|
|
293
|
+
return parseEnvFileText(text);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (isMissingFileError(error)) return undefined;
|
|
296
|
+
options.logger?.warn?.(`ObservMe env file ${path} was ignored: ${formatError(error)}`);
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function defaultReadConfigText(path: string): Promise<string> {
|
|
302
|
+
return readFile(path, "utf8");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function readOptionalText(path: string, readText: ReadConfigText): Promise<string | undefined> {
|
|
306
|
+
try {
|
|
307
|
+
return await readText(path);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (isMissingFileError(error)) return undefined;
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function resolveGlobalConfigPath(options: LoadConfigOptions): string {
|
|
315
|
+
return options.globalConfigPath ?? join(homedir(), CONFIG_DIR_NAME, "agent", observmeYamlFileName);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function resolveProjectConfigPath(options: LoadSessionConfigOptions): string {
|
|
319
|
+
return resolveProjectLocalFilePath({
|
|
320
|
+
cwd: options.cwd,
|
|
321
|
+
configDirName: options.configDirName,
|
|
322
|
+
defaultConfigDirName: CONFIG_DIR_NAME,
|
|
323
|
+
fileName: observmeYamlFileName,
|
|
324
|
+
overridePath: options.projectConfigPath,
|
|
325
|
+
inputLabel: "project config path",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function resolveProjectEnvFilePath(options: LoadSessionConfigOptions): string {
|
|
330
|
+
return resolveProjectLocalFilePath({
|
|
331
|
+
cwd: options.cwd,
|
|
332
|
+
fileName: defaultEnvFileName,
|
|
333
|
+
overridePath: options.envFilePath ?? defaultEnvFileName,
|
|
334
|
+
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)}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function resolveProjectTrust(options: LoadSessionConfigOptions): Promise<boolean> {
|
|
343
|
+
if (typeof options.isProjectTrusted === "boolean") return options.isProjectTrusted;
|
|
344
|
+
if (typeof options.isProjectTrusted === "function") return options.isProjectTrusted();
|
|
345
|
+
if (typeof options.ctx?.isProjectTrusted === "function") return options.ctx.isProjectTrusted();
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function mergeEnvironment(envFile: NodeJS.ProcessEnv | undefined, environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
350
|
+
if (!envFile) return environment;
|
|
351
|
+
return { ...envFile, ...environment };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function cloneConfig<T>(value: T): T {
|
|
355
|
+
return structuredClone(value);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function deepMergeObjects(base: ConfigObject, overlay: ConfigObject): ConfigObject {
|
|
359
|
+
const merged = cloneConfig(base);
|
|
360
|
+
|
|
361
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
362
|
+
if (value === undefined) continue;
|
|
363
|
+
const existing = Object.hasOwn(merged, key) ? merged[key] : undefined;
|
|
364
|
+
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
365
|
+
assignConfigProperty(merged, key, deepMergeObjects(existing, value));
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
assignConfigProperty(merged, key, cloneConfig(value));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return merged;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function parseEnvFileText(text: string): NodeJS.ProcessEnv {
|
|
375
|
+
const env: NodeJS.ProcessEnv = {};
|
|
376
|
+
const lines = text.split(/\r?\n/u);
|
|
377
|
+
|
|
378
|
+
lines.forEach((line, index) => {
|
|
379
|
+
const entry = parseEnvFileLine(line, index + 1);
|
|
380
|
+
if (entry) env[entry.key] = entry.value;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
return env;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function parseEnvFileLine(line: string, lineNumber: number): { key: string; value: string } | undefined {
|
|
387
|
+
const trimmed = line.trim();
|
|
388
|
+
if (!trimmed || trimmed.startsWith("#")) return undefined;
|
|
389
|
+
|
|
390
|
+
const assignment = stripEnvExportPrefix(trimmed);
|
|
391
|
+
const separatorIndex = assignment.indexOf("=");
|
|
392
|
+
if (separatorIndex <= 0) throw new Error(`Invalid .env line ${lineNumber}: expected KEY=value.`);
|
|
393
|
+
|
|
394
|
+
const key = assignment.slice(0, separatorIndex).trim();
|
|
395
|
+
if (!isValidEnvKey(key)) throw new Error(`Invalid .env line ${lineNumber}: environment variable name is invalid.`);
|
|
396
|
+
|
|
397
|
+
return { key, value: parseEnvValue(assignment.slice(separatorIndex + 1)) };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function stripEnvExportPrefix(line: string): string {
|
|
401
|
+
if (!line.startsWith("export ")) return line;
|
|
402
|
+
return line.slice("export ".length).trimStart();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function parseEnvValue(valueText: string): string {
|
|
406
|
+
const trimmed = valueText.trimStart();
|
|
407
|
+
if (!trimmed) return "";
|
|
408
|
+
if (trimmed.startsWith("\"") || trimmed.startsWith("'")) return parseQuotedEnvValue(trimmed);
|
|
409
|
+
return stripEnvInlineComment(valueText).trim();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function parseQuotedEnvValue(valueText: string): string {
|
|
413
|
+
const closingIndex = findClosingEnvQuote(valueText);
|
|
414
|
+
if (closingIndex === -1) throw new Error("Invalid .env quoted value.");
|
|
415
|
+
return unquoteEnvValue(valueText.slice(0, closingIndex + 1));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function findClosingEnvQuote(valueText: string): number {
|
|
419
|
+
const quote = valueText[0];
|
|
420
|
+
|
|
421
|
+
for (let index = 1; index < valueText.length; index += 1) {
|
|
422
|
+
if (valueText[index] === quote && valueText[index - 1] !== "\\") return index;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return -1;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function unquoteEnvValue(valueText: string): string {
|
|
429
|
+
const quote = valueText[0];
|
|
430
|
+
const value = valueText.slice(1, -1);
|
|
431
|
+
if (quote === "'") return value;
|
|
432
|
+
return value.replace(/\\([nrt"\\])/gu, (_match, escaped: string) => decodeEscapedEnvCharacter(escaped));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function decodeEscapedEnvCharacter(escaped: string): string {
|
|
436
|
+
if (escaped === "n") return "\n";
|
|
437
|
+
if (escaped === "r") return "\r";
|
|
438
|
+
if (escaped === "t") return "\t";
|
|
439
|
+
return escaped;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function stripEnvInlineComment(valueText: string): string {
|
|
443
|
+
const commentIndex = valueText.search(/\s#/u);
|
|
444
|
+
if (commentIndex === -1) return valueText;
|
|
445
|
+
return valueText.slice(0, commentIndex);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function isValidEnvKey(key: string): boolean {
|
|
449
|
+
return /^[A-Za-z_]\w*$/u.test(key);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function parseSimpleYaml(text: string): ConfigObject {
|
|
453
|
+
const root: ConfigObject = {};
|
|
454
|
+
const lines = normalizeYamlLines(text);
|
|
455
|
+
const stack: Array<{ indent: number; value: ConfigObject | ConfigValue[] }> = [{ indent: -1, value: root }];
|
|
456
|
+
|
|
457
|
+
lines.forEach((line, index) => {
|
|
458
|
+
while (stack.length > 1 && line.indent <= stack.at(-1)!.indent) stack.pop();
|
|
459
|
+
|
|
460
|
+
const parent = stack.at(-1)!.value;
|
|
461
|
+
if (line.text.startsWith("- ")) {
|
|
462
|
+
applyYamlArrayItem(parent, line, stack);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const { key, valueText } = splitYamlKeyValue(line.text);
|
|
467
|
+
if (valueText === undefined) {
|
|
468
|
+
const child = nextYamlContainer(lines, index);
|
|
469
|
+
assignYamlProperty(parent, key, child);
|
|
470
|
+
stack.push({ indent: line.indent, value: child });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
assignYamlProperty(parent, key, parseYamlScalar(valueText));
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
return root;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function normalizeYamlLines(text: string): ParsedYamlLine[] {
|
|
481
|
+
return text
|
|
482
|
+
.split("\n")
|
|
483
|
+
.map(line => ({ indent: yamlIndentPattern.exec(line)?.[0].length ?? 0, text: stripYamlComment(line).trim() }))
|
|
484
|
+
.filter(line => line.text !== "");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function applyYamlArrayItem(
|
|
488
|
+
parent: ConfigObject | ConfigValue[],
|
|
489
|
+
line: ParsedYamlLine,
|
|
490
|
+
stack: Array<{ indent: number; value: ConfigObject | ConfigValue[] }>,
|
|
491
|
+
) {
|
|
492
|
+
if (!Array.isArray(parent)) throw new Error("Invalid YAML: array item is not nested under an array key.");
|
|
493
|
+
|
|
494
|
+
const itemText = line.text.slice(2).trim();
|
|
495
|
+
const keyValue = trySplitYamlKeyValue(itemText);
|
|
496
|
+
if (!keyValue) {
|
|
497
|
+
parent.push(parseYamlScalar(itemText));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const item: ConfigObject = {};
|
|
502
|
+
parent.push(item);
|
|
503
|
+
stack.push({ indent: line.indent, value: item });
|
|
504
|
+
|
|
505
|
+
if (keyValue.valueText === undefined) {
|
|
506
|
+
const child: ConfigObject = {};
|
|
507
|
+
assignConfigProperty(item, keyValue.key, child);
|
|
508
|
+
stack.push({ indent: line.indent + 2, value: child });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
assignConfigProperty(item, keyValue.key, parseYamlScalar(keyValue.valueText));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function nextYamlContainer(lines: ParsedYamlLine[], index: number): ConfigObject | ConfigValue[] {
|
|
516
|
+
const nextLine = lines[index + 1];
|
|
517
|
+
return nextLine?.text.startsWith("- ") ? [] : {};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function assignYamlProperty(parent: ConfigObject | ConfigValue[], key: string, value: ConfigValue) {
|
|
521
|
+
if (Array.isArray(parent)) throw new Error(`Invalid YAML: cannot assign key ${key} directly to an array.`);
|
|
522
|
+
assignConfigProperty(parent, key, value);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function assignConfigProperty(parent: ConfigObject, key: string, value: ConfigValue) {
|
|
526
|
+
Object.defineProperty(parent, key, {
|
|
527
|
+
value,
|
|
528
|
+
enumerable: true,
|
|
529
|
+
configurable: true,
|
|
530
|
+
writable: true,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function splitYamlKeyValue(text: string): { key: string; valueText?: string } {
|
|
535
|
+
const keyValue = trySplitYamlKeyValue(text);
|
|
536
|
+
if (!keyValue) throw new Error(`Invalid YAML line: ${text}`);
|
|
537
|
+
return keyValue;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function trySplitYamlKeyValue(text: string): { key: string; valueText?: string } | undefined {
|
|
541
|
+
const separatorIndex = text.indexOf(":");
|
|
542
|
+
if (separatorIndex === -1) return undefined;
|
|
543
|
+
|
|
544
|
+
const key = text.slice(0, separatorIndex).trim();
|
|
545
|
+
const rawValue = text.slice(separatorIndex + 1).trim();
|
|
546
|
+
return { key, valueText: rawValue === "" ? undefined : rawValue };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function parseYamlScalar(valueText: string): ConfigValue {
|
|
550
|
+
if (valueText === "true") return true;
|
|
551
|
+
if (valueText === "false") return false;
|
|
552
|
+
if (valueText === "null") return null;
|
|
553
|
+
if (valueText === "{}") return {};
|
|
554
|
+
if (valueText === "[]") return [];
|
|
555
|
+
if (/^-?\d+(?:\.\d+)?$/.test(valueText)) return Number(valueText);
|
|
556
|
+
if (isQuoted(valueText)) return valueText.slice(1, -1);
|
|
557
|
+
return valueText;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function stripYamlComment(line: string): string {
|
|
561
|
+
let quote: string | undefined;
|
|
562
|
+
|
|
563
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
564
|
+
const char = line[index];
|
|
565
|
+
if ((char === '"' || char === "'") && line[index - 1] !== "\\") {
|
|
566
|
+
quote = quote === char ? undefined : quote ?? char;
|
|
567
|
+
}
|
|
568
|
+
if (char === "#" && quote === undefined) return line.slice(0, index);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return line;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function isQuoted(text: string): boolean {
|
|
575
|
+
return (text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function setAuthorizationHeader(config: DeepPartial<ObservMeConfig>, token: string | undefined) {
|
|
579
|
+
if (!token) return;
|
|
580
|
+
setString(config, ["otlp", "headers", "Authorization"], `Bearer ${token}`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function setString(target: DeepPartial<ObservMeConfig>, path: string[], value: string | undefined) {
|
|
584
|
+
if (value === undefined || value === "") return;
|
|
585
|
+
setPathValue(target as Record<string, unknown>, path, value);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
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);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function setBoolean(target: DeepPartial<ObservMeConfig>, path: string[], value: string | undefined) {
|
|
596
|
+
const parsed = parseBooleanEnv(value);
|
|
597
|
+
if (parsed === undefined) return;
|
|
598
|
+
setPathValue(target as Record<string, unknown>, path, parsed);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function setPathValue(target: Record<string, unknown>, path: string[], value: unknown) {
|
|
602
|
+
let current = target;
|
|
603
|
+
|
|
604
|
+
for (const segment of path.slice(0, -1)) {
|
|
605
|
+
const existing = current[segment];
|
|
606
|
+
if (!isPlainObject(existing)) current[segment] = {};
|
|
607
|
+
current = current[segment] as Record<string, unknown>;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
current[path.at(-1)!] = value;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
|
614
|
+
if (value === undefined || value === "") return undefined;
|
|
615
|
+
const normalized = value.toLowerCase();
|
|
616
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
617
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
618
|
+
return undefined;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function isPlainObject(value: unknown): value is ConfigObject {
|
|
622
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function isMissingFileError(error: unknown): boolean {
|
|
626
|
+
return isPlainObject(error) && error.code === "ENOENT";
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function formatError(error: unknown): string {
|
|
630
|
+
return error instanceof Error ? error.message : String(error);
|
|
631
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
export interface ProjectLocalFilePathOptions {
|
|
4
|
+
readonly cwd?: string;
|
|
5
|
+
readonly configDirName?: string;
|
|
6
|
+
readonly defaultConfigDirName?: string;
|
|
7
|
+
readonly fileName: string;
|
|
8
|
+
readonly overridePath?: string;
|
|
9
|
+
readonly inputLabel: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const parentSegment = "..";
|
|
13
|
+
|
|
14
|
+
export function resolveProjectLocalFilePath(options: ProjectLocalFilePathOptions): string {
|
|
15
|
+
const root = resolve(options.cwd ?? process.cwd());
|
|
16
|
+
const candidatePath = resolveProjectLocalCandidatePath(root, options);
|
|
17
|
+
|
|
18
|
+
if (!isPathInsideOrSame(root, candidatePath)) throw new Error(createUnsafeProjectPathMessage(options.inputLabel));
|
|
19
|
+
if (shouldPreserveRelativeOverride(options)) return options.overridePath!;
|
|
20
|
+
return candidatePath;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveProjectLocalCandidatePath(root: string, options: ProjectLocalFilePathOptions): string {
|
|
24
|
+
if (options.overridePath !== undefined) return resolveOverridePath(root, options.overridePath, options.inputLabel);
|
|
25
|
+
return resolveConfigDirPath(root, options);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function shouldPreserveRelativeOverride(options: ProjectLocalFilePathOptions): boolean {
|
|
29
|
+
return options.cwd === undefined && options.overridePath !== undefined && !isAbsolute(options.overridePath);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveOverridePath(root: string, overridePath: string, inputLabel: string): string {
|
|
33
|
+
if (isRelativeTraversalPath(overridePath)) throw new Error(createUnsafeProjectPathMessage(inputLabel));
|
|
34
|
+
return isAbsolute(overridePath) ? resolve(overridePath) : resolve(root, overridePath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveConfigDirPath(root: string, options: ProjectLocalFilePathOptions): string {
|
|
38
|
+
const configDirName = options.configDirName ?? options.defaultConfigDirName;
|
|
39
|
+
|
|
40
|
+
if (!configDirName) throw new Error(createUnsafeProjectPathMessage(options.inputLabel));
|
|
41
|
+
if (isAbsolute(configDirName)) throw new Error(createUnsafeProjectPathMessage(options.inputLabel));
|
|
42
|
+
if (hasParentSegment(configDirName)) throw new Error(createUnsafeProjectPathMessage(options.inputLabel));
|
|
43
|
+
return resolve(root, join(configDirName, options.fileName));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isRelativeTraversalPath(pathInput: string): boolean {
|
|
47
|
+
return !isAbsolute(pathInput) && hasParentSegment(pathInput);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasParentSegment(pathInput: string): boolean {
|
|
51
|
+
return pathInput.split(/[\\/]+/u).includes(parentSegment);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isPathInsideOrSame(root: string, candidatePath: string): boolean {
|
|
55
|
+
const pathFromRoot = relative(root, candidatePath);
|
|
56
|
+
return pathFromRoot === "" || (!pathFromRoot.startsWith(parentSegment) && !isAbsolute(pathFromRoot));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createUnsafeProjectPathMessage(inputLabel: string): string {
|
|
60
|
+
return `Unsafe ObservMe ${inputLabel}: path input must stay inside the active project root.`;
|
|
61
|
+
}
|