@intelli-1113/stratos-sdk 1.3.4 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## Change version in package.json
2
+ ## npm login
3
+ ## npm publish --access public
4
+
1
5
  # @intelli-1113/stratos-sdk
2
6
 
3
7
  **One install** for [Stratos AI](https://github.com) agent observability:
package/package.json CHANGED
@@ -1,15 +1,23 @@
1
1
  {
2
2
  "name": "@intelli-1113/stratos-sdk",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
4
4
  "description": "Stratos AI agent telemetry (OpenLLMetry), Vercel AI auto-spans, LangChain inspect/fix, MCP proxy.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
8
8
  ".": "./src/index.js",
9
9
  "./register": "./src/register.js",
10
- "./inspect": "./src/inspect-langchain.js"
10
+ "./inspect": "./src/inspect-langchain.js",
11
+ "./otel-bridge": "./src/otel-bridge.js",
12
+ "./otel-bridge-harness": "./src/otel-bridge-harness.js",
13
+ "./execution-spans": "./src/execution-spans.js",
14
+ "./config-templates": "./src/config-templates.js"
11
15
  },
12
16
  "bin": {
17
+ "stratos": "./src/cli.js",
18
+ "stratos-setup": "./src/cli-setup.js",
19
+ "stratos-doctor": "./src/cli-doctor.js",
20
+ "stratos-otel-bridge": "./src/otel-bridge.js",
13
21
  "stratos-mcp-proxy": "./src/mcp-proxy.js",
14
22
  "stratos-inspect": "./src/cli-inspect.js"
15
23
  },
@@ -19,6 +27,8 @@
19
27
  "scripts": {
20
28
  "postinstall": "node ./src/postinstall.js",
21
29
  "test": "node -e \"import('./src/index.js').then(m=>console.log('ok',Object.keys(m)))\"",
30
+ "setup": "node ./src/cli.js setup claude-code",
31
+ "doctor": "node ./src/cli.js doctor",
22
32
  "inspect": "node ./src/cli-inspect.js"
23
33
  },
24
34
  "keywords": [
@@ -35,6 +45,7 @@
35
45
  "dependencies": {
36
46
  "@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
37
47
  "@traceloop/node-server-sdk": "^0.27.0",
48
+ "cross-spawn": "^7.0.3",
38
49
  "import-in-the-middle": "^1.15.0",
39
50
  "require-in-the-middle": "^7.5.2"
40
51
  },
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ export async function doctorCommand() {
8
+ console.log("[stratos-doctor] Running diagnostics...\n");
9
+
10
+ const checks = [];
11
+ let passed = 0;
12
+ let failed = 0;
13
+
14
+ const token = process.env.STRATOS_TOKEN;
15
+ const url = process.env.STRATOS_URL || "https://stratos.anthropic.com";
16
+
17
+ if (token && token.startsWith("sk_")) {
18
+ console.log("✓ STRATOS_TOKEN set");
19
+ passed++;
20
+ } else {
21
+ console.log("✗ STRATOS_TOKEN missing or invalid (must start with sk_)");
22
+ checks.push("Set STRATOS_TOKEN environment variable with your enrollment token");
23
+ failed++;
24
+ }
25
+
26
+ if (url && url.startsWith("https://")) {
27
+ console.log(`✓ STRATOS_URL configured: ${url}`);
28
+ passed++;
29
+ } else {
30
+ console.log("✗ STRATOS_URL missing or invalid (must be https://)");
31
+ checks.push("Set STRATOS_URL environment variable");
32
+ failed++;
33
+ }
34
+
35
+ if (process.env.NODE_OPTIONS?.includes("stratos-sdk/register")) {
36
+ console.log("✓ NODE_OPTIONS includes stratos-sdk/register");
37
+ passed++;
38
+ } else {
39
+ console.log("✗ NODE_OPTIONS not set to include stratos-sdk/register");
40
+ checks.push(
41
+ "Add to NODE_OPTIONS: --import @intelli-1113/stratos-sdk/register"
42
+ );
43
+ failed++;
44
+ }
45
+
46
+ if (process.env.STRATOS_RECORD_INPUTS === "0") {
47
+ console.log("✓ STRATOS_RECORD_INPUTS=0 (content-off)");
48
+ passed++;
49
+ } else {
50
+ console.log("⚠ STRATOS_RECORD_INPUTS not set to 0 (prompts may be recorded)");
51
+ checks.push("Set STRATOS_RECORD_INPUTS=0 to disable prompt recording");
52
+ }
53
+
54
+ if (process.env.STRATOS_RECORD_OUTPUTS === "0") {
55
+ console.log("✓ STRATOS_RECORD_OUTPUTS=0 (content-off)");
56
+ passed++;
57
+ } else {
58
+ console.log(
59
+ "⚠ STRATOS_RECORD_OUTPUTS not set to 0 (responses may be recorded)"
60
+ );
61
+ checks.push("Set STRATOS_RECORD_OUTPUTS=0 to disable response recording");
62
+ }
63
+
64
+ const claudeDir = join(homedir(), ".claude");
65
+ const settingsPath = join(claudeDir, "settings.json");
66
+ const envPath = join(claudeDir, ".env");
67
+
68
+ if (existsSync(settingsPath)) {
69
+ console.log(`✓ Settings file exists: ${settingsPath}`);
70
+ passed++;
71
+ } else {
72
+ console.log(`✗ Settings file missing: ${settingsPath}`);
73
+ checks.push("Run: stratos setup claude-code");
74
+ failed++;
75
+ }
76
+
77
+ if (existsSync(envPath)) {
78
+ console.log(`✓ Environment file exists: ${envPath}`);
79
+ passed++;
80
+ } else {
81
+ console.log(`⚠ Env file missing: ${envPath}`);
82
+ checks.push("Run: stratos setup claude-code");
83
+ }
84
+
85
+ if (token && url && failed === 0) {
86
+ try {
87
+ console.log("\n[stratos-doctor] Testing connectivity...");
88
+ const response = await fetch(`${url}/api/health`, {
89
+ method: "GET",
90
+ headers: {
91
+ "x-stratos-token": token,
92
+ "user-agent": "stratos-sdk-doctor",
93
+ },
94
+ timeout: 5000,
95
+ });
96
+
97
+ if (response.ok) {
98
+ console.log("✓ Connected to Stratos server");
99
+ passed++;
100
+ } else if (response.status === 401 || response.status === 403) {
101
+ console.log(`✗ Stratos server rejected token (${response.status})`);
102
+ checks.push("Verify STRATOS_TOKEN is correct");
103
+ failed++;
104
+ } else {
105
+ console.log(`✗ Stratos server returned ${response.status}`);
106
+ checks.push(`Check STRATOS_URL (got ${response.status})`);
107
+ failed++;
108
+ }
109
+ } catch (err) {
110
+ console.log(`✗ Could not connect to Stratos server: ${err.message}`);
111
+ checks.push(
112
+ `Check STRATOS_URL is reachable: ${url}\n` +
113
+ `Common issues:\n` +
114
+ ` - Network connectivity\n` +
115
+ ` - Firewall blocking\n` +
116
+ ` - Invalid URL`
117
+ );
118
+ failed++;
119
+ }
120
+ }
121
+
122
+ console.log("\n" + "=".repeat(60));
123
+ if (failed === 0) {
124
+ console.log("✓ stratos-doctor: All checks passed");
125
+ console.log(
126
+ "\nYou are connected to Stratos. Your Claude usage will now be tracked."
127
+ );
128
+ } else {
129
+ console.log(`✗ stratos-doctor: ${failed} check(s) failed\n`);
130
+ if (checks.length > 0) {
131
+ console.log("Remediation steps:");
132
+ checks.forEach((check, i) => {
133
+ console.log(` ${i + 1}. ${check}`);
134
+ });
135
+ }
136
+ process.exit(1);
137
+ }
138
+ console.log("=".repeat(60) + "\n");
139
+ }
140
+
141
+ doctorCommand().catch((err) => {
142
+ console.error("[stratos-doctor] Fatal error:", err.message);
143
+ process.exit(1);
144
+ });
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
4
+ import { homedir, platform } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { execSync } from "node:child_process";
7
+
8
+ export async function setupCommand(surface) {
9
+ if (!surface) {
10
+ console.error("[stratos-setup] Error: Surface required");
11
+ console.error("Usage: stratos setup <surface>");
12
+ console.error("Example: stratos setup claude-code");
13
+ process.exit(1);
14
+ }
15
+
16
+ if (surface !== "claude-code") {
17
+ console.error(`[stratos-setup] Error: Unsupported surface '${surface}'`);
18
+ console.error("Currently supported: claude-code");
19
+ process.exit(1);
20
+ }
21
+
22
+ console.log(`[stratos-setup] Configuring Stratos for ${surface}...\n`);
23
+
24
+ const claudeCodePath = detectClaudeCode();
25
+ if (!claudeCodePath) {
26
+ console.error("[stratos-setup] Error: Claude Code not detected on this machine");
27
+ console.error("Install Claude Code from https://claude.ai/code before running setup.");
28
+ process.exit(1);
29
+ }
30
+
31
+ console.log(`✓ Found Claude Code at: ${claudeCodePath}\n`);
32
+
33
+ const token = process.env.STRATOS_TOKEN;
34
+ if (!token) {
35
+ console.error("[stratos-setup] Error: STRATOS_TOKEN environment variable not set");
36
+ console.error("Provide your enrollment token via: export STRATOS_TOKEN=sk_...");
37
+ process.exit(1);
38
+ }
39
+
40
+ const url = process.env.STRATOS_URL || "https://stratos.anthropic.com";
41
+ console.log(`✓ Using Stratos server: ${url}\n`);
42
+
43
+ const claudeDir = join(homedir(), ".claude");
44
+ mkdirSync(claudeDir, { recursive: true });
45
+
46
+ const envPath = join(claudeDir, ".env");
47
+ const envContent = `STRATOS_TOKEN=${token}
48
+ STRATOS_URL=${url}
49
+ STRATOS_RECORD_INPUTS=0
50
+ STRATOS_RECORD_OUTPUTS=0
51
+ NODE_OPTIONS=--import @intelli-1113/stratos-sdk/register
52
+ `;
53
+
54
+ writeFileSync(envPath, envContent, "utf8");
55
+ console.log(`✓ Wrote config to: ${envPath}`);
56
+
57
+ const settingsPath = join(claudeDir, "settings.json");
58
+ let settings = {};
59
+ if (existsSync(settingsPath)) {
60
+ try {
61
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
62
+ } catch {
63
+ console.warn("[stratos-setup] Warning: Could not parse existing settings.json, will merge");
64
+ }
65
+ }
66
+
67
+ settings.env = settings.env || {};
68
+ settings.env.STRATOS_TOKEN = token;
69
+ settings.env.STRATOS_URL = url;
70
+ settings.env.STRATOS_RECORD_INPUTS = "0";
71
+ settings.env.STRATOS_RECORD_OUTPUTS = "0";
72
+ settings.env.NODE_OPTIONS = "--import @intelli-1113/stratos-sdk/register";
73
+
74
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
75
+ console.log(`✓ Updated: ${settingsPath}\n`);
76
+
77
+ console.log("[stratos-setup] Setup complete. Running doctor to verify...\n");
78
+
79
+ const { doctorCommand } = await import("./cli-doctor.js");
80
+ await doctorCommand();
81
+ }
82
+
83
+ function detectClaudeCode() {
84
+ const os = platform();
85
+ const home = homedir();
86
+
87
+ if (os === "darwin") {
88
+ const macPath = join(home, "Applications", "Claude.app");
89
+ if (existsSync(macPath)) return macPath;
90
+ }
91
+
92
+ if (os === "win32") {
93
+ const paths = [
94
+ join(home, "AppData", "Local", "Programs", "claude"),
95
+ "C:\\Program Files\\Claude",
96
+ "C:\\Program Files (x86)\\Claude",
97
+ ];
98
+ for (const p of paths) {
99
+ if (existsSync(p)) return p;
100
+ }
101
+ }
102
+
103
+ if (os === "linux") {
104
+ const paths = [
105
+ join(home, ".local", "share", "applications", "claude"),
106
+ join(home, ".config", "claude"),
107
+ ];
108
+ for (const p of paths) {
109
+ if (existsSync(p)) return p;
110
+ }
111
+ }
112
+
113
+ try {
114
+ const result = execSync("which claude 2>/dev/null || where claude 2>nul", {
115
+ encoding: "utf8",
116
+ }).trim();
117
+ if (result) return result;
118
+ } catch {}
119
+
120
+ return null;
121
+ }
package/src/cli.js ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from "node:util";
4
+ import { setupCommand } from "./cli-setup.js";
5
+ import { doctorCommand } from "./cli-doctor.js";
6
+
7
+ const VERSION = "1.3.4";
8
+
9
+ function printHelp() {
10
+ console.log(`
11
+ stratos CLI ${VERSION}
12
+
13
+ Usage:
14
+ stratos <command> [options]
15
+
16
+ Commands:
17
+ setup <surface> Configure Stratos on a surface (e.g., claude-code)
18
+ doctor Validate Stratos setup and connectivity
19
+ --help, -h Show this message
20
+ --version, -v Show version
21
+
22
+ Examples:
23
+ stratos setup claude-code
24
+ stratos doctor
25
+ `);
26
+ }
27
+
28
+ async function main() {
29
+ const { values, positional } = parseArgs({
30
+ options: {
31
+ help: { type: "boolean", short: "h" },
32
+ version: { type: "boolean", short: "v" },
33
+ },
34
+ allowPositionals: true,
35
+ });
36
+
37
+ if (values.help) {
38
+ printHelp();
39
+ process.exit(0);
40
+ }
41
+
42
+ if (values.version) {
43
+ console.log(`stratos ${VERSION}`);
44
+ process.exit(0);
45
+ }
46
+
47
+ const [command, ...args] = positional;
48
+
49
+ if (!command) {
50
+ printHelp();
51
+ process.exit(0);
52
+ }
53
+
54
+ try {
55
+ if (command === "setup") {
56
+ await setupCommand(...args);
57
+ } else if (command === "doctor") {
58
+ await doctorCommand();
59
+ } else {
60
+ console.error(`Unknown command: ${command}`);
61
+ printHelp();
62
+ process.exit(1);
63
+ }
64
+ } catch (err) {
65
+ console.error(`[stratos] Error:`, err.message);
66
+ process.exit(1);
67
+ }
68
+ }
69
+
70
+ main();
@@ -0,0 +1,183 @@
1
+ import { platform } from "node:os";
2
+ import { writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ export class ConfigTemplate {
6
+ constructor(opts = {}) {
7
+ this.personEmail = opts.personEmail || "alice@company.com";
8
+ this.surface = opts.surface || "claude-code"; // claude-code, claude-chat, claude-cowork
9
+ this.token = opts.token || `sk_dev_${opts.personEmail.split("@")[0]}_${opts.surface}_${Date.now()}`;
10
+ this.url = opts.url || "https://stratos.company.com";
11
+ this.osType = opts.osType || platform();
12
+ this.contentOff = opts.contentOff !== false;
13
+ this.recordInputs = opts.recordInputs === true ? "1" : "0";
14
+ this.recordOutputs = opts.recordOutputs === true ? "1" : "0";
15
+ }
16
+
17
+ toEnv() {
18
+ return `STRATOS_TOKEN=${this.token}
19
+ STRATOS_URL=${this.url}
20
+ STRATOS_SURFACE=${this.surface}
21
+ STRATOS_RECORD_INPUTS=${this.recordInputs}
22
+ STRATOS_RECORD_OUTPUTS=${this.recordOutputs}
23
+ NODE_OPTIONS=--import @intelli-1113/stratos-sdk/register
24
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
25
+ `;
26
+ }
27
+
28
+ toSettingsJson() {
29
+ return {
30
+ surface: this.surface,
31
+ owner_email: this.personEmail,
32
+ env: {
33
+ STRATOS_TOKEN: this.token,
34
+ STRATOS_URL: this.url,
35
+ STRATOS_SURFACE: this.surface,
36
+ STRATOS_RECORD_INPUTS: this.recordInputs,
37
+ STRATOS_RECORD_OUTPUTS: this.recordOutputs,
38
+ NODE_OPTIONS: "--import @intelli-1113/stratos-sdk/register",
39
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
40
+ },
41
+ hooks: {
42
+ PreToolUse: { enabled: true },
43
+ PostToolUse: { enabled: true },
44
+ SessionStart: { enabled: true },
45
+ SessionStop: { enabled: true },
46
+ SubagentStop: { enabled: true },
47
+ UserPromptSubmit: { enabled: true },
48
+ },
49
+ };
50
+ }
51
+
52
+ toYaml() {
53
+ const settings = this.toSettingsJson();
54
+ return `# Stratos SDK Configuration
55
+ # Generated for: ${this.personEmail}
56
+ # Surface: ${this.surface}
57
+ # OS: ${this.osType}
58
+
59
+ surface: ${settings.surface}
60
+ owner_email: ${settings.owner_email}
61
+
62
+ env:
63
+ ${Object.entries(settings.env)
64
+ .map(([k, v]) => ` ${k}: ${v}`)
65
+ .join("\n")}
66
+
67
+ hooks:
68
+ ${Object.entries(settings.hooks)
69
+ .map(([k, v]) => ` ${k}: ${v.enabled}`)
70
+ .join("\n")}
71
+
72
+ content_off: ${this.contentOff}
73
+ created_at: ${new Date().toISOString()}
74
+ `;
75
+ }
76
+
77
+ toBundle() {
78
+ return {
79
+ version: "1.0",
80
+ person: this.personEmail,
81
+ surface: this.surface,
82
+ os: this.osType,
83
+ config: this.toSettingsJson(),
84
+ env_file: this.toEnv(),
85
+ yaml_template: this.toYaml(),
86
+ metadata: {
87
+ created_at: new Date().toISOString(),
88
+ sdk_version: "1.4.0",
89
+ },
90
+ };
91
+ }
92
+
93
+ save(dirPath) {
94
+ const fileName = `stratos-config-${this.personEmail.replace(/@.*/, "")}-${this.surface}`;
95
+ const yamlPath = join(dirPath, `${fileName}.yaml`);
96
+ const jsonPath = join(dirPath, `${fileName}.json`);
97
+
98
+ writeFileSync(yamlPath, this.toYaml(), "utf8");
99
+ writeFileSync(jsonPath, JSON.stringify(this.toBundle(), null, 2), "utf8");
100
+
101
+ return { yamlPath, jsonPath };
102
+ }
103
+ }
104
+
105
+ export class MDMBundleGenerator {
106
+ constructor(opts = {}) {
107
+ this.orgUrl = opts.orgUrl || "https://stratos.company.com";
108
+ this.adminToken = opts.adminToken;
109
+ this.templates = [];
110
+ }
111
+
112
+ addTemplate(template) {
113
+ this.templates.push(template);
114
+ }
115
+
116
+ generateForPerson(personEmail, surfaces = ["claude-code", "claude-chat", "claude-cowork"]) {
117
+ const templates = [];
118
+ for (const surface of surfaces) {
119
+ const template = new ConfigTemplate({
120
+ personEmail,
121
+ surface,
122
+ url: this.orgUrl,
123
+ });
124
+ templates.push(template);
125
+ }
126
+ return templates;
127
+ }
128
+
129
+ toManifest() {
130
+ return {
131
+ version: "1.0",
132
+ org_url: this.orgUrl,
133
+ bundles: this.templates.map((t) => t.toBundle()),
134
+ metadata: {
135
+ created_at: new Date().toISOString(),
136
+ total_bundles: this.templates.length,
137
+ total_people: new Set(this.templates.map((t) => t.personEmail)).size,
138
+ total_surfaces: new Set(this.templates.map((t) => t.surface)).size,
139
+ },
140
+ };
141
+ }
142
+
143
+ async postToEnrollment(enrollmentUrl) {
144
+ if (!this.adminToken) {
145
+ console.error("[mdm-bundle] Admin token required to post enrollment");
146
+ return false;
147
+ }
148
+
149
+ try {
150
+ const manifest = this.toManifest();
151
+ const res = await fetch(`${enrollmentUrl}/api/mdm/bundle`, {
152
+ method: "POST",
153
+ headers: {
154
+ "Content-Type": "application/json",
155
+ Authorization: `Bearer ${this.adminToken}`,
156
+ },
157
+ body: JSON.stringify(manifest),
158
+ });
159
+
160
+ if (!res.ok) {
161
+ console.error(`[mdm-bundle] Enrollment returned ${res.status}`);
162
+ return false;
163
+ }
164
+
165
+ console.log("[mdm-bundle] Bundle posted to enrollment successfully");
166
+ return true;
167
+ } catch (err) {
168
+ console.error(`[mdm-bundle] Failed to post: ${err.message}`);
169
+ return false;
170
+ }
171
+ }
172
+ }
173
+
174
+ export function createDefaultMDMBundle(personEmails, orgUrl) {
175
+ const generator = new MDMBundleGenerator({ orgUrl });
176
+
177
+ for (const email of personEmails) {
178
+ const templates = generator.generateForPerson(email);
179
+ templates.forEach((t) => generator.addTemplate(t));
180
+ }
181
+
182
+ return generator;
183
+ }
@@ -73,7 +73,9 @@ function providerHosts() {
73
73
  .split(",")
74
74
  .map((s) => s.trim())
75
75
  .filter(Boolean);
76
- return [...DEFAULT_PROVIDER_HOSTS, ...extra];
76
+ // P1-02: Add "api." catch-all to block future/unlisted providers (NVIDIA, DeepSeek, Fireworks, xAI, etc.)
77
+ // while keeping explicit list for clarity
78
+ return [...DEFAULT_PROVIDER_HOSTS, ...extra, "api."];
77
79
  }
78
80
 
79
81
  // Never guard Stratos's own host: the agent has to be able to reach Stratos to
@@ -160,4 +162,23 @@ function patchRequestModule(mod) {
160
162
  };
161
163
  guarded.__stratosGuarded = true;
162
164
  mod.request = guarded;
165
+
166
+ // P1-01: Also wrap .get() to catch https.get() and named imports like `import { get } from "node:https"`
167
+ if (mod.get && !mod.get.__stratosGuarded) {
168
+ const realGet = mod.get;
169
+ const guardedGet = function stratosGuardedGet(...args) {
170
+ const opts = args.find((a) => a && typeof a === "object" && typeof a !== "function");
171
+ const host = opts ? String(opts.hostname || opts.host || "") : hostFromInput(args[0]);
172
+ if (isGuardedHost(host)) {
173
+ const { blocked, reason } = isBlocked();
174
+ if (blocked) throw new StratosBudgetBlockedError(reason);
175
+ }
176
+ return realGet.apply(mod, args);
177
+ };
178
+ guardedGet.__stratosGuarded = true;
179
+ mod.get = guardedGet;
180
+ }
181
+
182
+ // Ensure named imports (import { request, get } from "node:https") see wrapped versions
183
+ module.syncBuiltinESMExports?.();
163
184
  }
@@ -0,0 +1,166 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ const TOKEN = process.env.STRATOS_TOKEN || "";
4
+ const STRATOS_URL = process.env.STRATOS_URL || "http://localhost:4000";
5
+ const SURFACE = process.env.STRATOS_SURFACE || "unknown";
6
+
7
+ const hdr = { "Content-Type": "application/json", "x-stratos-token": TOKEN };
8
+
9
+ export class ExecutionSpan {
10
+ constructor(opts = {}) {
11
+ this.type = opts.type; // skill, subagent, hook, cmd, plugin, mcp
12
+ this.name = opts.name;
13
+ this.startTime = opts.startTime || Date.now();
14
+ this.endTime = opts.endTime || null;
15
+ this.status = opts.status || "running"; // running, success, error
16
+ this.reason = opts.reason || null;
17
+ this.metadata = opts.metadata || {};
18
+ }
19
+
20
+ end(status = "success", reason = null) {
21
+ this.endTime = Date.now();
22
+ this.status = status;
23
+ this.reason = reason;
24
+ }
25
+
26
+ toOtlpSpan() {
27
+ const traceId = (randomBytes(16)).toString("hex");
28
+ const spanId = (randomBytes(8)).toString("hex");
29
+ const startNano = BigInt(this.startTime) * 1000000n;
30
+ const endNano = BigInt(this.endTime || Date.now()) * 1000000n;
31
+
32
+ const attr = (k, v) => ({
33
+ key: k,
34
+ value: {
35
+ stringValue: String(v),
36
+ },
37
+ });
38
+
39
+ const attributes = [
40
+ attr("traceloop.span.kind", this.type),
41
+ attr("traceloop.entity.name", this.name),
42
+ attr("stratos.surface", SURFACE),
43
+ attr("stratos.execution.type", this.type),
44
+ attr("stratos.execution.status", this.status),
45
+ ];
46
+
47
+ if (this.reason) {
48
+ attributes.push(attr("stratos.execution.reason", this.reason));
49
+ }
50
+
51
+ for (const [k, v] of Object.entries(this.metadata || {})) {
52
+ if (v != null && typeof v !== "object") {
53
+ attributes.push(attr(`stratos.${k}`, v));
54
+ }
55
+ }
56
+
57
+ return {
58
+ traceId,
59
+ spanId,
60
+ name: `${this.type}:${this.name}`,
61
+ startTimeUnixNano: startNano.toString(),
62
+ endTimeUnixNano: endNano.toString(),
63
+ attributes,
64
+ };
65
+ }
66
+
67
+ static async post(span) {
68
+ if (!TOKEN) return;
69
+
70
+ const otlpSpan = span.toOtlpSpan();
71
+ const payload = {
72
+ resourceSpans: [
73
+ {
74
+ resource: {
75
+ attributes: [{ key: "service.name", value: { stringValue: SURFACE } }],
76
+ },
77
+ scopeSpans: [
78
+ {
79
+ spans: [otlpSpan],
80
+ },
81
+ ],
82
+ },
83
+ ],
84
+ };
85
+
86
+ try {
87
+ const res = await fetch(`${STRATOS_URL}/api/ingest`, {
88
+ method: "POST",
89
+ headers: hdr,
90
+ body: JSON.stringify(payload),
91
+ });
92
+ if (!res.ok) {
93
+ console.error(`[execution-spans] Stratos returned ${res.status}`);
94
+ }
95
+ } catch (err) {
96
+ console.error(`[execution-spans] Failed to post span: ${err.message}`);
97
+ }
98
+ }
99
+ }
100
+
101
+ export async function postSkillSpan(skillName, status = "success", reason = null) {
102
+ const span = new ExecutionSpan({
103
+ type: "skill",
104
+ name: skillName,
105
+ status,
106
+ reason,
107
+ });
108
+ span.end(status, reason);
109
+ await ExecutionSpan.post(span);
110
+ }
111
+
112
+ export async function postSubagentSpan(subagentName, status = "success", reason = null, metadata = {}) {
113
+ const span = new ExecutionSpan({
114
+ type: "subagent",
115
+ name: subagentName,
116
+ status,
117
+ reason,
118
+ metadata,
119
+ });
120
+ span.end(status, reason);
121
+ await ExecutionSpan.post(span);
122
+ }
123
+
124
+ export async function postHookSpan(hookName, status = "success", reason = null) {
125
+ const span = new ExecutionSpan({
126
+ type: "hook",
127
+ name: hookName,
128
+ status,
129
+ reason,
130
+ });
131
+ span.end(status, reason);
132
+ await ExecutionSpan.post(span);
133
+ }
134
+
135
+ export async function postToolSpan(toolName, status = "success", reason = null) {
136
+ const span = new ExecutionSpan({
137
+ type: "mcp",
138
+ name: toolName,
139
+ status,
140
+ reason,
141
+ });
142
+ span.end(status, reason);
143
+ await ExecutionSpan.post(span);
144
+ }
145
+
146
+ export async function postCommandSpan(cmd, status = "success", reason = null) {
147
+ const span = new ExecutionSpan({
148
+ type: "cmd",
149
+ name: cmd,
150
+ status,
151
+ reason,
152
+ });
153
+ span.end(status, reason);
154
+ await ExecutionSpan.post(span);
155
+ }
156
+
157
+ export async function postPluginSpan(pluginName, status = "success", reason = null) {
158
+ const span = new ExecutionSpan({
159
+ type: "plugin",
160
+ name: pluginName,
161
+ status,
162
+ reason,
163
+ });
164
+ span.end(status, reason);
165
+ await ExecutionSpan.post(span);
166
+ }
package/src/index.js CHANGED
@@ -149,9 +149,11 @@ export function start(opts = {}) {
149
149
  process.env.STRATOS_APP_NAME ||
150
150
  process.env.npm_package_name ||
151
151
  "agent";
152
- const heartbeatMs = Number(
152
+ // P1-03: Prevent STRATOS_HEARTBEAT_MS=0 from disabling heartbeat (unblockable agent)
153
+ // Minimum 1s to allow normal config but prevent 0
154
+ const heartbeatMs = Math.max(1000, Number(
153
155
  opts.heartbeatMs || process.env.STRATOS_HEARTBEAT_MS || 30000
154
- );
156
+ ));
155
157
 
156
158
  if (!token) {
157
159
  console.warn(
@@ -164,10 +166,18 @@ export function start(opts = {}) {
164
166
  const ingest = `${origin}/api/ingest`;
165
167
  const heartbeatUrl = `${origin}/api/heartbeat`;
166
168
 
169
+ // P1-04: Helper to check if a feature flag is enabled (matches Vercel AI convention)
170
+ function flagEnabled(key) {
171
+ const val = process.env[key];
172
+ return val !== "0" && val !== "false";
173
+ }
174
+
167
175
  traceloop.initialize({
168
176
  appName,
169
177
  disableBatch: true,
170
178
  logLevel: process.env.STRATOS_DEBUG ? "debug" : undefined,
179
+ // P1-04: Respect STRATOS_RECORD_INPUTS/OUTPUTS for all frameworks (not just Vercel AI)
180
+ traceContent: flagEnabled('STRATOS_RECORD_INPUTS') && flagEnabled('STRATOS_RECORD_OUTPUTS'),
171
181
  exporter: new OTLPTraceExporter({
172
182
  url: ingest,
173
183
  headers: { "x-stratos-token": token },
@@ -232,7 +242,14 @@ export function start(opts = {}) {
232
242
  },
233
243
  body: JSON.stringify(meta),
234
244
  })
235
- .then((r) => r.json().catch(() => null))
245
+ .then((r) => {
246
+ // P1-03: Treat 401/403 (revoked token) as blocked state
247
+ if (r.status === 401 || r.status === 403) {
248
+ setBlocked(true, "token revoked or unauthorized");
249
+ return null;
250
+ }
251
+ return r.json().catch(() => null);
252
+ })
236
253
  .then((data) => {
237
254
  if (!data || typeof data.blocked !== "boolean") return;
238
255
  setBlocked(data.blocked, data.reason);
@@ -9,7 +9,7 @@
9
9
  // npx stratos-inspect --fix --yes
10
10
 
11
11
  import { createRequire } from "node:module";
12
- import { existsSync, readFileSync } from "node:fs";
12
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { spawnSync } from "node:child_process";
15
15
  import { createInterface } from "node:readline";
@@ -317,6 +317,12 @@ export async function fixLangChain(opts = {}) {
317
317
  console.log(`\n[stratos-inspect] Using package manager: ${pm}`);
318
318
  console.log(`[stratos-inspect] Uninstalling old LangChain packages…`);
319
319
 
320
+ // P1-07: Back up package.json and lockfile before making changes
321
+ const pkgPath = join(cwd, "package.json");
322
+ const lockPath = join(cwd, `${pm}-lock.${pm === "yarn" ? "yaml" : pm === "bun" ? "lock" : "json"}`);
323
+ const pkgBackup = readFileSync(pkgPath, "utf8");
324
+ const lockBackup = existsSync(lockPath) ? readFileSync(lockPath, "utf8") : null;
325
+
320
326
  let uninstallOk = true;
321
327
  if (toRemove.length) {
322
328
  if (pm === "npm") {
@@ -353,9 +359,23 @@ export async function fixLangChain(opts = {}) {
353
359
  installOk = runPm("bun", ["add", ...installList], cwd);
354
360
  }
355
361
 
362
+ // P1-07: If install failed, restore backups to leave project in consistent state
363
+ if (!installOk) {
364
+ console.log(`[stratos-inspect] Restoring package.json and lockfile…`);
365
+ writeFileSync(pkgPath, pkgBackup);
366
+ if (lockBackup) writeFileSync(lockPath, lockBackup);
367
+ return {
368
+ ok: false,
369
+ step: "install",
370
+ plan,
371
+ reportBefore: report,
372
+ message: "Install failed — backups restored. Fix manually and re-run stratos-inspect --fix",
373
+ };
374
+ }
375
+
356
376
  const after = inspectLangChain({ cwd });
357
377
  return {
358
- ok: installOk && after.status === "ok",
378
+ ok: after.status === "ok",
359
379
  step: "install",
360
380
  plan,
361
381
  reportBefore: report,
package/src/mcp-proxy.js CHANGED
@@ -21,7 +21,8 @@
21
21
  // CRITICAL: stdout is the MCP channel — keep it clean. Route all logging to stderr.
22
22
  console.log = console.info = (...a) => process.stderr.write(a.map(String).join(" ") + "\n");
23
23
 
24
- import { spawn } from "node:child_process";
24
+ // P1-06: Use cross-spawn to safely handle arguments on Windows (avoids shell injection)
25
+ import spawn from "cross-spawn";
25
26
  import { randomBytes } from "node:crypto";
26
27
  // The network guard installed by register.js (see enforcement.js) patches
27
28
  // THIS process's fetch/http — it has no effect on the target MCP server, which
@@ -98,10 +99,21 @@ hbTimer.unref?.();
98
99
 
99
100
  const pendingCalls = new Map();
100
101
 
101
- const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "inherit"], shell: process.platform === "win32" });
102
+ // P1-06: cross-spawn handles Windows paths transparently without shell
103
+ const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "inherit"] });
102
104
  child.on("error", (e) => { process.stderr.write(`[stratos-proxy] failed to start target: ${e.message}\n`); process.exit(1); });
103
105
  child.on("exit", (code) => { try { heartbeat(); clearInterval(hbTimer); } catch {} process.exit(code == null ? 0 : code); });
104
106
 
107
+ // P1-05: Forward signals to child process and handle cleanup
108
+ for (const sig of ["SIGINT", "SIGTERM"]) {
109
+ process.on(sig, () => {
110
+ if (child.pid && !child.killed) {
111
+ child.kill(sig);
112
+ setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 2000);
113
+ }
114
+ });
115
+ }
116
+
105
117
  linePump(process.stdin, (line) => {
106
118
  let m; try { m = JSON.parse(line); } catch { child.stdin.write(line + "\n"); return; }
107
119
  if (m && m.id != null && m.method === "tools/call") {
@@ -148,5 +160,11 @@ function linePump(stream, onLine) {
148
160
  if (line.trim()) onLine(line);
149
161
  }
150
162
  });
151
- stream.on("end", () => { if (buf.trim()) onLine(buf); });
163
+ stream.on("end", () => {
164
+ if (buf.trim()) onLine(buf);
165
+ // P1-05: When host disconnects (stdin ends), close child's stdin to signal EOF
166
+ if (stream === process.stdin && child.stdin) {
167
+ child.stdin.end();
168
+ }
169
+ });
152
170
  }
@@ -0,0 +1,115 @@
1
+ import spawn from "cross-spawn";
2
+ import { setTimeout as sleep } from "node:timers/promises";
3
+
4
+ export class OtelBridgeHarness {
5
+ constructor(opts = {}) {
6
+ this.token = opts.token || process.env.STRATOS_TOKEN;
7
+ this.url = opts.url || process.env.STRATOS_URL || "http://localhost:4000";
8
+ this.surface = opts.surface || process.env.STRATOS_SURFACE || "unknown";
9
+ this.port = opts.port || 4318;
10
+ this.child = null;
11
+ this.restartCount = 0;
12
+ this.maxRestarts = 5;
13
+ this.restartDelay = 1000;
14
+ }
15
+
16
+ start() {
17
+ if (this.child) {
18
+ console.warn("[otel-bridge-harness] Already running");
19
+ return;
20
+ }
21
+
22
+ const env = {
23
+ ...process.env,
24
+ STRATOS_TOKEN: this.token,
25
+ STRATOS_URL: this.url,
26
+ STRATOS_SURFACE: this.surface,
27
+ OTEL_EXPORTER_OTLP_PORT: String(this.port),
28
+ };
29
+
30
+ this.child = spawn("node", [require.resolve("./otel-bridge.js")], {
31
+ stdio: ["ignore", "pipe", "pipe"],
32
+ env,
33
+ detached: false,
34
+ });
35
+
36
+ this.child.stdout.on("data", (data) => {
37
+ console.log(`[otel-bridge] ${data.toString().trim()}`);
38
+ });
39
+
40
+ this.child.stderr.on("data", (data) => {
41
+ console.error(`[otel-bridge] ${data.toString().trim()}`);
42
+ });
43
+
44
+ this.child.on("error", (err) => {
45
+ console.error(`[otel-bridge-harness] Error: ${err.message}`);
46
+ this.child = null;
47
+ this._scheduleRestart();
48
+ });
49
+
50
+ this.child.on("exit", (code) => {
51
+ console.warn(`[otel-bridge-harness] Bridge exited (code: ${code})`);
52
+ this.child = null;
53
+ if (code !== 0) {
54
+ this._scheduleRestart();
55
+ }
56
+ });
57
+
58
+ console.log(`[otel-bridge-harness] Started (PID: ${this.child.pid})`);
59
+ }
60
+
61
+ async _scheduleRestart() {
62
+ if (this.restartCount >= this.maxRestarts) {
63
+ console.error("[otel-bridge-harness] Max restarts exceeded. Giving up.");
64
+ return;
65
+ }
66
+
67
+ this.restartCount++;
68
+ const delay = this.restartDelay * Math.pow(2, this.restartCount - 1);
69
+ console.log(`[otel-bridge-harness] Restarting in ${delay}ms (attempt ${this.restartCount}/${this.maxRestarts})`);
70
+
71
+ await sleep(delay);
72
+ this.start();
73
+ }
74
+
75
+ stop() {
76
+ if (!this.child) return;
77
+
78
+ console.log("[otel-bridge-harness] Stopping...");
79
+ this.child.kill("SIGTERM");
80
+
81
+ setTimeout(() => {
82
+ if (this.child && !this.child.killed) {
83
+ console.warn("[otel-bridge-harness] Force killing after timeout");
84
+ this.child.kill("SIGKILL");
85
+ }
86
+ }, 2000);
87
+
88
+ this.child = null;
89
+ }
90
+
91
+ isRunning() {
92
+ return this.child != null && !this.child.killed;
93
+ }
94
+
95
+ getPid() {
96
+ return this.child?.pid || null;
97
+ }
98
+ }
99
+
100
+ export async function startOtelBridge(opts = {}) {
101
+ const harness = new OtelBridgeHarness(opts);
102
+ harness.start();
103
+
104
+ process.on("SIGINT", () => {
105
+ harness.stop();
106
+ process.exit(0);
107
+ });
108
+
109
+ process.on("SIGTERM", () => {
110
+ harness.stop();
111
+ process.exit(0);
112
+ });
113
+
114
+ return harness;
115
+ }
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+
3
+ import http from "node:http";
4
+ import { randomBytes } from "node:crypto";
5
+
6
+ const TOKEN = process.env.STRATOS_TOKEN || "";
7
+ const STRATOS_URL = process.env.STRATOS_URL || "http://localhost:4000";
8
+ const PORT = process.env.OTEL_EXPORTER_OTLP_PORT || 4318;
9
+ const SURFACE = process.env.STRATOS_SURFACE || "unknown";
10
+
11
+ if (!TOKEN) {
12
+ console.warn("[otel-bridge] Warning: STRATOS_TOKEN not set. Telemetry will not be forwarded.");
13
+ }
14
+
15
+ const hdr = { "Content-Type": "application/json", "x-stratos-token": TOKEN };
16
+
17
+ function redactContent(obj) {
18
+ if (!obj || typeof obj !== "object") return obj;
19
+ if (Array.isArray(obj)) return obj.map(redactContent);
20
+ const copy = { ...obj };
21
+ for (const key of Object.keys(copy)) {
22
+ if (key.includes("prompt") || key.includes("request") || key.includes("response") || key.includes("content")) {
23
+ copy[key] = null;
24
+ } else if (typeof copy[key] === "object") {
25
+ copy[key] = redactContent(copy[key]);
26
+ }
27
+ }
28
+ return copy;
29
+ }
30
+
31
+ function otlpToStratosSpan(otlpMetric) {
32
+ const traceId = (randomBytes(16)).toString("hex");
33
+ const spanId = (randomBytes(8)).toString("hex");
34
+ const now = BigInt(Date.now()) * 1000000n;
35
+
36
+ const attributes = otlpMetric.attributes || [];
37
+ const attrMap = {};
38
+ for (const attr of attributes) {
39
+ attrMap[attr.key] = attr.value?.stringValue || attr.value?.doubleValue || String(attr.value);
40
+ }
41
+
42
+ const redacted = redactContent(attrMap);
43
+
44
+ return {
45
+ traceId,
46
+ spanId,
47
+ name: `${SURFACE}.otel.usage`,
48
+ startTimeUnixNano: now.toString(),
49
+ endTimeUnixNano: now.toString(),
50
+ attributes: [
51
+ { key: "traceloop.span.kind", value: { stringValue: "llm" } },
52
+ { key: "stratos.surface", value: { stringValue: SURFACE } },
53
+ { key: "otel.bridge", value: { stringValue: "true" } },
54
+ ...Object.entries(redacted).map(([k, v]) => ({
55
+ key: `otel.${k}`,
56
+ value: { stringValue: String(v) },
57
+ })),
58
+ ],
59
+ };
60
+ }
61
+
62
+ function buildStratosPayload(otlpSpans) {
63
+ const attr = (k, v) => ({ key: k, value: { stringValue: String(v) } });
64
+ return {
65
+ resourceSpans: [
66
+ {
67
+ resource: { attributes: [attr("service.name", SURFACE)] },
68
+ scopeSpans: [
69
+ {
70
+ spans: otlpSpans.map((span) => ({
71
+ traceId: span.traceId,
72
+ spanId: span.spanId,
73
+ name: span.name,
74
+ startTimeUnixNano: span.startTimeUnixNano,
75
+ endTimeUnixNano: span.endTimeUnixNano,
76
+ attributes: span.attributes,
77
+ })),
78
+ },
79
+ ],
80
+ },
81
+ ],
82
+ };
83
+ }
84
+
85
+ const server = http.createServer(async (req, res) => {
86
+ const { method, url } = req;
87
+
88
+ if (url === "/v1/traces" && method === "POST") {
89
+ let body = "";
90
+ req.on("data", (chunk) => {
91
+ body += chunk.toString();
92
+ });
93
+
94
+ req.on("end", async () => {
95
+ try {
96
+ const otlpPayload = JSON.parse(body);
97
+ const otlpSpans = [];
98
+
99
+ for (const resourceSpan of otlpPayload.resourceSpans || []) {
100
+ for (const scopeSpan of resourceSpan.scopeSpans || []) {
101
+ for (const span of scopeSpan.spans || []) {
102
+ otlpSpans.push(otlpToStratosSpan(span));
103
+ }
104
+ }
105
+ }
106
+
107
+ if (otlpSpans.length > 0 && TOKEN) {
108
+ const stratosPayload = buildStratosPayload(otlpSpans);
109
+ try {
110
+ const fres = await fetch(`${STRATOS_URL}/api/ingest`, {
111
+ method: "POST",
112
+ headers: hdr,
113
+ body: JSON.stringify(stratosPayload),
114
+ });
115
+ if (!fres.ok) {
116
+ console.error(`[otel-bridge] Stratos returned ${fres.status}`);
117
+ }
118
+ } catch (err) {
119
+ console.error(`[otel-bridge] Failed to forward to Stratos: ${err.message}`);
120
+ }
121
+ }
122
+
123
+ res.writeHead(200, { "Content-Type": "application/json" });
124
+ res.end(JSON.stringify({}));
125
+ } catch (err) {
126
+ console.error(`[otel-bridge] Parse error: ${err.message}`);
127
+ res.writeHead(400, { "Content-Type": "application/json" });
128
+ res.end(JSON.stringify({ error: "parse error" }));
129
+ }
130
+ });
131
+ return;
132
+ }
133
+
134
+ if (url === "/v1/metrics" && method === "POST") {
135
+ req.on("data", () => {}); // Drain
136
+ req.on("end", () => {
137
+ res.writeHead(200, { "Content-Type": "application/json" });
138
+ res.end(JSON.stringify({}));
139
+ });
140
+ return;
141
+ }
142
+
143
+ if (url === "/health" && method === "GET") {
144
+ res.writeHead(200, { "Content-Type": "application/json" });
145
+ res.end(JSON.stringify({ status: "ok", surface: SURFACE }));
146
+ return;
147
+ }
148
+
149
+ res.writeHead(404, { "Content-Type": "application/json" });
150
+ res.end(JSON.stringify({ error: "not found" }));
151
+ });
152
+
153
+ server.listen(PORT, "localhost", () => {
154
+ console.log(
155
+ `[otel-bridge] listening on http://localhost:${PORT}` +
156
+ (TOKEN ? ` (forwarding to ${STRATOS_URL})` : " (token not set; telemetry disabled)")
157
+ );
158
+ console.log(`[otel-bridge] surface: ${SURFACE}`);
159
+ console.log(`[otel-bridge] Set OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:${PORT} in your app`);
160
+ });
161
+
162
+ process.on("SIGINT", () => {
163
+ console.log("[otel-bridge] shutting down");
164
+ server.close(() => process.exit(0));
165
+ });
166
+
167
+ process.on("SIGTERM", () => {
168
+ console.log("[otel-bridge] shutting down");
169
+ server.close(() => process.exit(0));
170
+ });