@intelli-1113/stratos-sdk 1.3.3 → 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 +4 -0
- package/package.json +13 -2
- package/src/cli-doctor.js +144 -0
- package/src/cli-setup.js +121 -0
- package/src/cli.js +70 -0
- package/src/config-templates.js +183 -0
- package/src/enforcement.js +22 -1
- package/src/execution-spans.js +166 -0
- package/src/index.js +53 -3
- package/src/inspect-langchain.js +22 -2
- package/src/mcp-proxy.js +21 -3
- package/src/otel-bridge-harness.js +115 -0
- package/src/otel-bridge.js +170 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intelli-1113/stratos-sdk",
|
|
3
|
-
"version": "1.3.
|
|
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
|
+
});
|
package/src/cli-setup.js
ADDED
|
@@ -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
|
+
}
|
package/src/enforcement.js
CHANGED
|
@@ -73,7 +73,9 @@ function providerHosts() {
|
|
|
73
73
|
.split(",")
|
|
74
74
|
.map((s) => s.trim())
|
|
75
75
|
.filter(Boolean);
|
|
76
|
-
|
|
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
|
@@ -90,6 +90,35 @@ function detectFramework() {
|
|
|
90
90
|
return null;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Warn user if no supported framework is detected.
|
|
95
|
+
* Provide list of supported frameworks and docs link.
|
|
96
|
+
*/
|
|
97
|
+
function warnUnsupportedFramework(detectedFramework) {
|
|
98
|
+
if (detectedFramework) return false; // Framework detected, no warning needed
|
|
99
|
+
|
|
100
|
+
const supportedFrameworks = [
|
|
101
|
+
"LangChain (@langchain/core, @langchain/langgraph)",
|
|
102
|
+
"OpenAI (@openai/agents, openai)",
|
|
103
|
+
"Anthropic (@anthropic-ai/sdk)",
|
|
104
|
+
"Vercel AI (ai)",
|
|
105
|
+
"Google GenAI (@google/genai, @google/generative-ai)",
|
|
106
|
+
"LlamaIndex (llamaindex)",
|
|
107
|
+
"CrewAI (crewai)",
|
|
108
|
+
"Google ADK (@google/adk, google-adk)",
|
|
109
|
+
"OpenAI Agents (@openai/agents)",
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const message = [
|
|
113
|
+
"[stratos] ⚠ No supported framework detected.",
|
|
114
|
+
"[stratos] Supported frameworks:",
|
|
115
|
+
...supportedFrameworks.map((fw) => `[stratos] • ${fw}`),
|
|
116
|
+
"[stratos] To add your framework: https://docs.stratos.ai/integrate",
|
|
117
|
+
].join("\n");
|
|
118
|
+
|
|
119
|
+
console.warn(message);
|
|
120
|
+
return true; // Flag indicating unsupported framework
|
|
121
|
+
}
|
|
93
122
|
|
|
94
123
|
function detectModel(opts) {
|
|
95
124
|
return (
|
|
@@ -120,9 +149,11 @@ export function start(opts = {}) {
|
|
|
120
149
|
process.env.STRATOS_APP_NAME ||
|
|
121
150
|
process.env.npm_package_name ||
|
|
122
151
|
"agent";
|
|
123
|
-
|
|
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(
|
|
124
155
|
opts.heartbeatMs || process.env.STRATOS_HEARTBEAT_MS || 30000
|
|
125
|
-
);
|
|
156
|
+
));
|
|
126
157
|
|
|
127
158
|
if (!token) {
|
|
128
159
|
console.warn(
|
|
@@ -135,10 +166,18 @@ export function start(opts = {}) {
|
|
|
135
166
|
const ingest = `${origin}/api/ingest`;
|
|
136
167
|
const heartbeatUrl = `${origin}/api/heartbeat`;
|
|
137
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
|
+
|
|
138
175
|
traceloop.initialize({
|
|
139
176
|
appName,
|
|
140
177
|
disableBatch: true,
|
|
141
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'),
|
|
142
181
|
exporter: new OTLPTraceExporter({
|
|
143
182
|
url: ingest,
|
|
144
183
|
headers: { "x-stratos-token": token },
|
|
@@ -183,6 +222,10 @@ export function start(opts = {}) {
|
|
|
183
222
|
tools: toolsEnv,
|
|
184
223
|
};
|
|
185
224
|
|
|
225
|
+
// Warn if no supported framework is detected
|
|
226
|
+
const isUnsupportedFramework = warnUnsupportedFramework(meta.framework);
|
|
227
|
+
meta.frameworkSupported = !isUnsupportedFramework;
|
|
228
|
+
|
|
186
229
|
// Heartbeat is also how the network guard (enforcement.js) learns whether to
|
|
187
230
|
// freeze this agent — budget exhausted, period crossed, or the agent
|
|
188
231
|
// manually disabled from the Control Tower all collapse into the same
|
|
@@ -199,7 +242,14 @@ export function start(opts = {}) {
|
|
|
199
242
|
},
|
|
200
243
|
body: JSON.stringify(meta),
|
|
201
244
|
})
|
|
202
|
-
.then((r) =>
|
|
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
|
+
})
|
|
203
253
|
.then((data) => {
|
|
204
254
|
if (!data || typeof data.blocked !== "boolean") return;
|
|
205
255
|
setBlocked(data.blocked, data.reason);
|
package/src/inspect-langchain.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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", () => {
|
|
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
|
+
});
|