@intelli-1113/stratos-sdk 1.3.4 → 1.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +17 -5
- package/src/README.md +583 -0
- package/src/USAGE.md +299 -0
- package/src/claude-code/hook.js +309 -0
- package/src/claude-code/settings.js +155 -0
- package/src/claude-code/transcript.js +259 -0
- package/src/cli-doctor.js +127 -0
- package/src/cli-env.js +134 -0
- package/src/cli-setup.js +171 -0
- package/src/cli.js +115 -0
- package/src/config-templates.js +148 -0
- package/src/enforcement.js +182 -163
- package/src/execution-spans.js +166 -0
- package/src/index.js +24 -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/src/postinstall.js +32 -11
package/src/enforcement.js
CHANGED
|
@@ -1,163 +1,182 @@
|
|
|
1
|
-
// Network-level budget / lifecycle enforcement.
|
|
2
|
-
//
|
|
3
|
-
// WHY THE NETWORK LAYER, NOT THE SDK LAYER
|
|
4
|
-
// -----------------------------------------------------------------------------
|
|
5
|
-
// OpenAI, Anthropic, LangChain, Vercel AI, Google GenAI — every one of them
|
|
6
|
-
// ultimately does the same thing: send an outbound HTTP request to the
|
|
7
|
-
// provider's API. Traceloop's own instrumentation (which this SDK depends on
|
|
8
|
-
// but does not own — @traceloop/instrumentation-openai et al.) exposes no
|
|
9
|
-
// pre-call hook that can refuse a request; its config surface is
|
|
10
|
-
// traceContent / enrichTokens / exceptionLogger / uploadBase64Image only.
|
|
11
|
-
// Wrapping every provider SDK's call function individually is also a moving
|
|
12
|
-
// target — a "custom agent" built directly on a raw SDK gets no protection
|
|
13
|
-
// until someone ships a wrapper for that specific library.
|
|
14
|
-
//
|
|
15
|
-
// Patching global `fetch` (and `http`/`https` as a fallback for older
|
|
16
|
-
// transports) catches all of them in ONE place, before the request leaves the
|
|
17
|
-
// process, with no per-provider code and no dependency on Traceloop's
|
|
18
|
-
// internals. This module is installed from register.js, before any provider
|
|
19
|
-
// SDK is imported by the agent's own code — the same ordering guarantee the
|
|
20
|
-
// Vercel AI ESM loader already relies on.
|
|
21
|
-
//
|
|
22
|
-
// This does NOT cover the MCP proxy path (src/mcp-proxy.js): that spawns a
|
|
23
|
-
// separate child process and proxies its stdio, so patching fetch in THIS
|
|
24
|
-
// process has no effect on the child's own network calls. The proxy has its
|
|
25
|
-
// own, necessary check at the JSON-RPC `tools/call` boundary instead.
|
|
26
|
-
|
|
27
|
-
import http from "node:http";
|
|
28
|
-
import https from "node:https";
|
|
29
|
-
|
|
30
|
-
export class StratosBudgetBlockedError extends Error {
|
|
31
|
-
constructor(reason) {
|
|
32
|
-
super(`[stratos] request blocked: ${reason || "budget or lifecycle limit reached"}`);
|
|
33
|
-
this.name = "StratosBudgetBlockedError";
|
|
34
|
-
this.stratosBlocked = true;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Cached from the heartbeat response (see index.js / mcp-proxy.js). Starts
|
|
39
|
-
// unblocked: an agent must never be frozen just because it hasn't heard back
|
|
40
|
-
// from Stratos yet — same fail-open philosophy as the server's own enforcement
|
|
41
|
-
// check (lib/quotaEnforce.ts: "fail open, loudly").
|
|
42
|
-
let _blocked = false;
|
|
43
|
-
let _reason = null;
|
|
44
|
-
|
|
45
|
-
export function setBlocked(blocked, reason) {
|
|
46
|
-
_blocked = !!blocked;
|
|
47
|
-
_reason = reason || null;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function isBlocked() {
|
|
51
|
-
return { blocked: _blocked, reason: _reason };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Hostnames of LLM/agent providers to guard. Extend without an SDK release via
|
|
55
|
-
// STRATOS_BLOCK_HOSTS (comma-separated substrings) — e.g. a private Azure
|
|
56
|
-
// OpenAI deployment hostname, or a self-hosted gateway.
|
|
57
|
-
const DEFAULT_PROVIDER_HOSTS = [
|
|
58
|
-
"api.openai.com",
|
|
59
|
-
"api.anthropic.com",
|
|
60
|
-
"generativelanguage.googleapis.com",
|
|
61
|
-
"aiplatform.googleapis.com",
|
|
62
|
-
"bedrock-runtime.", // *.amazonaws.com region-scoped, matched by substring
|
|
63
|
-
"api.cohere.ai",
|
|
64
|
-
"api.mistral.ai",
|
|
65
|
-
"openrouter.ai",
|
|
66
|
-
"api.together.xyz",
|
|
67
|
-
"api.groq.com",
|
|
68
|
-
"openai.azure.com",
|
|
69
|
-
];
|
|
70
|
-
|
|
71
|
-
function providerHosts() {
|
|
72
|
-
const extra = (process.env.STRATOS_BLOCK_HOSTS || "")
|
|
73
|
-
.split(",")
|
|
74
|
-
.map((s) => s.trim())
|
|
75
|
-
.filter(Boolean);
|
|
76
|
-
return [...DEFAULT_PROVIDER_HOSTS, ...extra];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Never guard Stratos's own host: the agent has to be able to reach Stratos to
|
|
80
|
-
// find out it has been unblocked. If this returned the wrong host the agent
|
|
81
|
-
// would deadlock itself — checked defensively, not just assumed.
|
|
82
|
-
function ownHost() {
|
|
83
|
-
try {
|
|
84
|
-
return new URL(process.env.STRATOS_URL || "http://localhost:4000").host;
|
|
85
|
-
} catch {
|
|
86
|
-
return "";
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function hostFromInput(input) {
|
|
91
|
-
try {
|
|
92
|
-
if (typeof input === "string") return new URL(input).host;
|
|
93
|
-
if (input instanceof URL) return input.host;
|
|
94
|
-
if (input && typeof input === "object") {
|
|
95
|
-
if (input.url) return new URL(input.url).host; // Request-like
|
|
96
|
-
if (input.hostname || input.host) return String(input.hostname || input.host);
|
|
97
|
-
}
|
|
98
|
-
} catch {
|
|
99
|
-
// Not a parseable URL. Let it through — this guard only ever restricts
|
|
100
|
-
// requests to a known provider host, never anything it can't identify.
|
|
101
|
-
}
|
|
102
|
-
return "";
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function isGuardedHost(host) {
|
|
106
|
-
if (!host) return false;
|
|
107
|
-
if (host === ownHost()) return false;
|
|
108
|
-
return providerHosts().some((h) => host.includes(h));
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
let installed = false;
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Patch global fetch and http(s).request so a blocked agent cannot reach any
|
|
115
|
-
* known LLM provider, regardless of which SDK it is using. Idempotent — safe
|
|
116
|
-
* to call more than once, and safe to call when no provider SDK is present.
|
|
117
|
-
*/
|
|
118
|
-
export function installNetworkGuard() {
|
|
119
|
-
if (installed) return;
|
|
120
|
-
installed = true;
|
|
121
|
-
|
|
122
|
-
if (typeof globalThis.fetch === "function" && !globalThis.fetch.__stratosGuarded) {
|
|
123
|
-
const realFetch = globalThis.fetch.bind(globalThis);
|
|
124
|
-
const guarded = function stratosGuardedFetch(input, init) {
|
|
125
|
-
if (isGuardedHost(hostFromInput(input))) {
|
|
126
|
-
const { blocked, reason } = isBlocked();
|
|
127
|
-
if (blocked) return Promise.reject(new StratosBudgetBlockedError(reason));
|
|
128
|
-
}
|
|
129
|
-
return realFetch(input, init);
|
|
130
|
-
};
|
|
131
|
-
guarded.__stratosGuarded = true;
|
|
132
|
-
globalThis.fetch = guarded;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
patchRequestModule(http);
|
|
136
|
-
patchRequestModule(https);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Secondary guard for legacy http(s)-based clients — the fetch guard above is
|
|
141
|
-
* the primary path and already covers current OpenAI / Anthropic / Google /
|
|
142
|
-
* Vercel AI SDKs, which all use fetch on Node 18+. A synchronous throw here is
|
|
143
|
-
* non-standard for a network failure (Node normally emits an async 'error' on
|
|
144
|
-
* the returned request), but building a spec-compliant fake ClientRequest for
|
|
145
|
-
* a fallback path most agents never exercise is not worth the complexity —
|
|
146
|
-
* callers of http(s).request that care will have it in a try/catch already,
|
|
147
|
-
* since the real function can also throw synchronously for invalid options.
|
|
148
|
-
*/
|
|
149
|
-
function patchRequestModule(mod) {
|
|
150
|
-
if (!mod || !mod.request || mod.request.__stratosGuarded) return;
|
|
151
|
-
const real = mod.request;
|
|
152
|
-
const guarded = function stratosGuardedRequest(...args) {
|
|
153
|
-
const opts = args.find((a) => a && typeof a === "object" && typeof a !== "function");
|
|
154
|
-
const host = opts ? String(opts.hostname || opts.host || "") : hostFromInput(args[0]);
|
|
155
|
-
if (isGuardedHost(host)) {
|
|
156
|
-
const { blocked, reason } = isBlocked();
|
|
157
|
-
if (blocked) throw new StratosBudgetBlockedError(reason);
|
|
158
|
-
}
|
|
159
|
-
return real.apply(mod, args);
|
|
160
|
-
};
|
|
161
|
-
guarded.__stratosGuarded = true;
|
|
162
|
-
mod.request = guarded;
|
|
163
|
-
|
|
1
|
+
// Network-level budget / lifecycle enforcement.
|
|
2
|
+
//
|
|
3
|
+
// WHY THE NETWORK LAYER, NOT THE SDK LAYER
|
|
4
|
+
// -----------------------------------------------------------------------------
|
|
5
|
+
// OpenAI, Anthropic, LangChain, Vercel AI, Google GenAI — every one of them
|
|
6
|
+
// ultimately does the same thing: send an outbound HTTP request to the
|
|
7
|
+
// provider's API. Traceloop's own instrumentation (which this SDK depends on
|
|
8
|
+
// but does not own — @traceloop/instrumentation-openai et al.) exposes no
|
|
9
|
+
// pre-call hook that can refuse a request; its config surface is
|
|
10
|
+
// traceContent / enrichTokens / exceptionLogger / uploadBase64Image only.
|
|
11
|
+
// Wrapping every provider SDK's call function individually is also a moving
|
|
12
|
+
// target — a "custom agent" built directly on a raw SDK gets no protection
|
|
13
|
+
// until someone ships a wrapper for that specific library.
|
|
14
|
+
//
|
|
15
|
+
// Patching global `fetch` (and `http`/`https` as a fallback for older
|
|
16
|
+
// transports) catches all of them in ONE place, before the request leaves the
|
|
17
|
+
// process, with no per-provider code and no dependency on Traceloop's
|
|
18
|
+
// internals. This module is installed from register.js, before any provider
|
|
19
|
+
// SDK is imported by the agent's own code — the same ordering guarantee the
|
|
20
|
+
// Vercel AI ESM loader already relies on.
|
|
21
|
+
//
|
|
22
|
+
// This does NOT cover the MCP proxy path (src/mcp-proxy.js): that spawns a
|
|
23
|
+
// separate child process and proxies its stdio, so patching fetch in THIS
|
|
24
|
+
// process has no effect on the child's own network calls. The proxy has its
|
|
25
|
+
// own, necessary check at the JSON-RPC `tools/call` boundary instead.
|
|
26
|
+
|
|
27
|
+
import http from "node:http";
|
|
28
|
+
import https from "node:https";
|
|
29
|
+
|
|
30
|
+
export class StratosBudgetBlockedError extends Error {
|
|
31
|
+
constructor(reason) {
|
|
32
|
+
super(`[stratos] request blocked: ${reason || "budget or lifecycle limit reached"}`);
|
|
33
|
+
this.name = "StratosBudgetBlockedError";
|
|
34
|
+
this.stratosBlocked = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Cached from the heartbeat response (see index.js / mcp-proxy.js). Starts
|
|
39
|
+
// unblocked: an agent must never be frozen just because it hasn't heard back
|
|
40
|
+
// from Stratos yet — same fail-open philosophy as the server's own enforcement
|
|
41
|
+
// check (lib/quotaEnforce.ts: "fail open, loudly").
|
|
42
|
+
let _blocked = false;
|
|
43
|
+
let _reason = null;
|
|
44
|
+
|
|
45
|
+
export function setBlocked(blocked, reason) {
|
|
46
|
+
_blocked = !!blocked;
|
|
47
|
+
_reason = reason || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isBlocked() {
|
|
51
|
+
return { blocked: _blocked, reason: _reason };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Hostnames of LLM/agent providers to guard. Extend without an SDK release via
|
|
55
|
+
// STRATOS_BLOCK_HOSTS (comma-separated substrings) — e.g. a private Azure
|
|
56
|
+
// OpenAI deployment hostname, or a self-hosted gateway.
|
|
57
|
+
const DEFAULT_PROVIDER_HOSTS = [
|
|
58
|
+
"api.openai.com",
|
|
59
|
+
"api.anthropic.com",
|
|
60
|
+
"generativelanguage.googleapis.com",
|
|
61
|
+
"aiplatform.googleapis.com",
|
|
62
|
+
"bedrock-runtime.", // *.amazonaws.com region-scoped, matched by substring
|
|
63
|
+
"api.cohere.ai",
|
|
64
|
+
"api.mistral.ai",
|
|
65
|
+
"openrouter.ai",
|
|
66
|
+
"api.together.xyz",
|
|
67
|
+
"api.groq.com",
|
|
68
|
+
"openai.azure.com",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
function providerHosts() {
|
|
72
|
+
const extra = (process.env.STRATOS_BLOCK_HOSTS || "")
|
|
73
|
+
.split(",")
|
|
74
|
+
.map((s) => s.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
return [...DEFAULT_PROVIDER_HOSTS, ...extra];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Never guard Stratos's own host: the agent has to be able to reach Stratos to
|
|
80
|
+
// find out it has been unblocked. If this returned the wrong host the agent
|
|
81
|
+
// would deadlock itself — checked defensively, not just assumed.
|
|
82
|
+
function ownHost() {
|
|
83
|
+
try {
|
|
84
|
+
return new URL(process.env.STRATOS_URL || "http://localhost:4000").host;
|
|
85
|
+
} catch {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hostFromInput(input) {
|
|
91
|
+
try {
|
|
92
|
+
if (typeof input === "string") return new URL(input).host;
|
|
93
|
+
if (input instanceof URL) return input.host;
|
|
94
|
+
if (input && typeof input === "object") {
|
|
95
|
+
if (input.url) return new URL(input.url).host; // Request-like
|
|
96
|
+
if (input.hostname || input.host) return String(input.hostname || input.host);
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Not a parseable URL. Let it through — this guard only ever restricts
|
|
100
|
+
// requests to a known provider host, never anything it can't identify.
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isGuardedHost(host) {
|
|
106
|
+
if (!host) return false;
|
|
107
|
+
if (host === ownHost()) return false;
|
|
108
|
+
return providerHosts().some((h) => host.includes(h));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let installed = false;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Patch global fetch and http(s).request so a blocked agent cannot reach any
|
|
115
|
+
* known LLM provider, regardless of which SDK it is using. Idempotent — safe
|
|
116
|
+
* to call more than once, and safe to call when no provider SDK is present.
|
|
117
|
+
*/
|
|
118
|
+
export function installNetworkGuard() {
|
|
119
|
+
if (installed) return;
|
|
120
|
+
installed = true;
|
|
121
|
+
|
|
122
|
+
if (typeof globalThis.fetch === "function" && !globalThis.fetch.__stratosGuarded) {
|
|
123
|
+
const realFetch = globalThis.fetch.bind(globalThis);
|
|
124
|
+
const guarded = function stratosGuardedFetch(input, init) {
|
|
125
|
+
if (isGuardedHost(hostFromInput(input))) {
|
|
126
|
+
const { blocked, reason } = isBlocked();
|
|
127
|
+
if (blocked) return Promise.reject(new StratosBudgetBlockedError(reason));
|
|
128
|
+
}
|
|
129
|
+
return realFetch(input, init);
|
|
130
|
+
};
|
|
131
|
+
guarded.__stratosGuarded = true;
|
|
132
|
+
globalThis.fetch = guarded;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
patchRequestModule(http);
|
|
136
|
+
patchRequestModule(https);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Secondary guard for legacy http(s)-based clients — the fetch guard above is
|
|
141
|
+
* the primary path and already covers current OpenAI / Anthropic / Google /
|
|
142
|
+
* Vercel AI SDKs, which all use fetch on Node 18+. A synchronous throw here is
|
|
143
|
+
* non-standard for a network failure (Node normally emits an async 'error' on
|
|
144
|
+
* the returned request), but building a spec-compliant fake ClientRequest for
|
|
145
|
+
* a fallback path most agents never exercise is not worth the complexity —
|
|
146
|
+
* callers of http(s).request that care will have it in a try/catch already,
|
|
147
|
+
* since the real function can also throw synchronously for invalid options.
|
|
148
|
+
*/
|
|
149
|
+
function patchRequestModule(mod) {
|
|
150
|
+
if (!mod || !mod.request || mod.request.__stratosGuarded) return;
|
|
151
|
+
const real = mod.request;
|
|
152
|
+
const guarded = function stratosGuardedRequest(...args) {
|
|
153
|
+
const opts = args.find((a) => a && typeof a === "object" && typeof a !== "function");
|
|
154
|
+
const host = opts ? String(opts.hostname || opts.host || "") : hostFromInput(args[0]);
|
|
155
|
+
if (isGuardedHost(host)) {
|
|
156
|
+
const { blocked, reason } = isBlocked();
|
|
157
|
+
if (blocked) throw new StratosBudgetBlockedError(reason);
|
|
158
|
+
}
|
|
159
|
+
return real.apply(mod, args);
|
|
160
|
+
};
|
|
161
|
+
guarded.__stratosGuarded = true;
|
|
162
|
+
mod.request = guarded;
|
|
163
|
+
|
|
164
|
+
// P1-01: Also wrap .get() to catch https.get() and named imports like `import { get } from "node:https"`
|
|
165
|
+
if (mod.get && !mod.get.__stratosGuarded) {
|
|
166
|
+
const realGet = mod.get;
|
|
167
|
+
const guardedGet = function stratosGuardedGet(...args) {
|
|
168
|
+
const opts = args.find((a) => a && typeof a === "object" && typeof a !== "function");
|
|
169
|
+
const host = opts ? String(opts.hostname || opts.host || "") : hostFromInput(args[0]);
|
|
170
|
+
if (isGuardedHost(host)) {
|
|
171
|
+
const { blocked, reason } = isBlocked();
|
|
172
|
+
if (blocked) throw new StratosBudgetBlockedError(reason);
|
|
173
|
+
}
|
|
174
|
+
return realGet.apply(mod, args);
|
|
175
|
+
};
|
|
176
|
+
guardedGet.__stratosGuarded = true;
|
|
177
|
+
mod.get = guardedGet;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Ensure named imports (import { request, get } from "node:https") see wrapped versions
|
|
181
|
+
module.syncBuiltinESMExports?.();
|
|
182
|
+
}
|
|
@@ -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
|
-
|
|
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(
|
|
@@ -161,13 +163,25 @@ export function start(opts = {}) {
|
|
|
161
163
|
}
|
|
162
164
|
started = true;
|
|
163
165
|
|
|
166
|
+
// Display SDK version on app startup
|
|
167
|
+
const SDK_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
168
|
+
console.log(`[stratos] Stratos SDK v${SDK_VERSION} initialized`);
|
|
169
|
+
|
|
164
170
|
const ingest = `${origin}/api/ingest`;
|
|
165
171
|
const heartbeatUrl = `${origin}/api/heartbeat`;
|
|
166
172
|
|
|
173
|
+
// P1-04: Helper to check if a feature flag is enabled (matches Vercel AI convention)
|
|
174
|
+
function flagEnabled(key) {
|
|
175
|
+
const val = process.env[key];
|
|
176
|
+
return val !== "0" && val !== "false";
|
|
177
|
+
}
|
|
178
|
+
|
|
167
179
|
traceloop.initialize({
|
|
168
180
|
appName,
|
|
169
181
|
disableBatch: true,
|
|
170
182
|
logLevel: process.env.STRATOS_DEBUG ? "debug" : undefined,
|
|
183
|
+
// P1-04: Respect STRATOS_RECORD_INPUTS/OUTPUTS for all frameworks (not just Vercel AI)
|
|
184
|
+
traceContent: flagEnabled('STRATOS_RECORD_INPUTS') && flagEnabled('STRATOS_RECORD_OUTPUTS'),
|
|
171
185
|
exporter: new OTLPTraceExporter({
|
|
172
186
|
url: ingest,
|
|
173
187
|
headers: { "x-stratos-token": token },
|
|
@@ -232,7 +246,14 @@ export function start(opts = {}) {
|
|
|
232
246
|
},
|
|
233
247
|
body: JSON.stringify(meta),
|
|
234
248
|
})
|
|
235
|
-
.then((r) =>
|
|
249
|
+
.then((r) => {
|
|
250
|
+
// P1-03: Treat 401/403 (revoked token) as blocked state
|
|
251
|
+
if (r.status === 401 || r.status === 403) {
|
|
252
|
+
setBlocked(true, "token revoked or unauthorized");
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return r.json().catch(() => null);
|
|
256
|
+
})
|
|
236
257
|
.then((data) => {
|
|
237
258
|
if (!data || typeof data.blocked !== "boolean") return;
|
|
238
259
|
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
|
}
|