codex-agent-view 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +34 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +327 -0
- package/assets/logo-dark.svg +13 -0
- package/assets/logo.svg +13 -0
- package/bin/codex-agent-view.mjs +489 -0
- package/hooks/hooks.json +62 -0
- package/package.json +59 -0
- package/public/app.js +637 -0
- package/public/index.html +137 -0
- package/public/styles.css +821 -0
- package/scripts/capture-hook.mjs +143 -0
- package/scripts/send-hook.mjs +64 -0
- package/skills/codex-agent-view/SKILL.md +21 -0
- package/src/core/index.mjs +3 -0
- package/src/core/monitor-store.mjs +332 -0
- package/src/core/normalize-hook-payload.mjs +146 -0
- package/src/runtime/config.mjs +111 -0
- package/src/runtime/server.mjs +203 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmod,
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
rename,
|
|
7
|
+
unlink,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
export const LOOPBACK_HOST = "127.0.0.1";
|
|
15
|
+
export const DEFAULT_PORT = 43127;
|
|
16
|
+
export const MAX_EVENT_BODY_BYTES = 64 * 1024;
|
|
17
|
+
export const RUNTIME_SCHEMA_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
export function runtimeDirectory(env = process.env) {
|
|
20
|
+
return resolve(
|
|
21
|
+
env.CODEX_AGENT_VIEW_RUNTIME_DIR || join(homedir(), ".codex-agent-view"),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runtimeFile(env = process.env) {
|
|
26
|
+
return join(runtimeDirectory(env), "runtime.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createRuntimeToken() {
|
|
30
|
+
return randomBytes(32).toString("base64url");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function ensurePrivateDirectory(directory) {
|
|
34
|
+
await rejectSymlink(directory);
|
|
35
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
36
|
+
await rejectSymlink(directory);
|
|
37
|
+
await chmod(directory, 0o700);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function rejectSymlink(path) {
|
|
41
|
+
try {
|
|
42
|
+
const stats = await lstat(path);
|
|
43
|
+
if (stats.isSymbolicLink()) {
|
|
44
|
+
throw new Error(`refusing symbolic link runtime path: ${path}`);
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error?.code !== "ENOENT") {
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function writeRuntimeInfo(info, env = process.env) {
|
|
54
|
+
const path = runtimeFile(env);
|
|
55
|
+
const directory = dirname(path);
|
|
56
|
+
await ensurePrivateDirectory(directory);
|
|
57
|
+
await rejectSymlink(path);
|
|
58
|
+
|
|
59
|
+
const temporaryPath = join(
|
|
60
|
+
directory,
|
|
61
|
+
`.runtime-${process.pid}-${randomBytes(8).toString("hex")}.tmp`,
|
|
62
|
+
);
|
|
63
|
+
const serialized = `${JSON.stringify(info, null, 2)}\n`;
|
|
64
|
+
await writeFile(temporaryPath, serialized, {
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
mode: 0o600,
|
|
67
|
+
flag: "wx",
|
|
68
|
+
});
|
|
69
|
+
await chmod(temporaryPath, 0o600);
|
|
70
|
+
await rename(temporaryPath, path);
|
|
71
|
+
await chmod(path, 0o600);
|
|
72
|
+
return path;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function readRuntimeInfo(env = process.env) {
|
|
76
|
+
const path = runtimeFile(env);
|
|
77
|
+
await rejectSymlink(path);
|
|
78
|
+
const raw = await readFile(path, "utf8");
|
|
79
|
+
const value = JSON.parse(raw);
|
|
80
|
+
if (
|
|
81
|
+
value === null ||
|
|
82
|
+
typeof value !== "object" ||
|
|
83
|
+
value.schema_version !== RUNTIME_SCHEMA_VERSION ||
|
|
84
|
+
value.host !== LOOPBACK_HOST ||
|
|
85
|
+
!Number.isInteger(value.port) ||
|
|
86
|
+
value.port < 1 ||
|
|
87
|
+
value.port > 65535 ||
|
|
88
|
+
typeof value.token !== "string" ||
|
|
89
|
+
value.token.length < 32
|
|
90
|
+
) {
|
|
91
|
+
throw new Error("invalid Codex Agent View runtime file");
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function removeRuntimeInfo(expectedToken, env = process.env) {
|
|
97
|
+
const path = runtimeFile(env);
|
|
98
|
+
try {
|
|
99
|
+
const current = await readRuntimeInfo(env);
|
|
100
|
+
if (current.token !== expectedToken) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
await unlink(path);
|
|
104
|
+
return true;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.code === "ENOENT") {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { createMonitorStore } from "../core/index.mjs";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_PORT,
|
|
9
|
+
LOOPBACK_HOST,
|
|
10
|
+
MAX_EVENT_BODY_BYTES,
|
|
11
|
+
RUNTIME_SCHEMA_VERSION,
|
|
12
|
+
createRuntimeToken,
|
|
13
|
+
removeRuntimeInfo,
|
|
14
|
+
writeRuntimeInfo,
|
|
15
|
+
} from "./config.mjs";
|
|
16
|
+
|
|
17
|
+
const STATIC_FILES = new Map([
|
|
18
|
+
["/", { url: new URL("../../public/index.html", import.meta.url), type: "text/html; charset=utf-8" }],
|
|
19
|
+
["/assets/app.js", { url: new URL("../../public/app.js", import.meta.url), type: "text/javascript; charset=utf-8" }],
|
|
20
|
+
["/assets/styles.css", { url: new URL("../../public/styles.css", import.meta.url), type: "text/css; charset=utf-8" }],
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const SECURITY_HEADERS = {
|
|
24
|
+
"cache-control": "no-store",
|
|
25
|
+
"content-security-policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
|
|
26
|
+
"cross-origin-opener-policy": "same-origin",
|
|
27
|
+
"referrer-policy": "no-referrer",
|
|
28
|
+
"x-content-type-options": "nosniff",
|
|
29
|
+
"x-frame-options": "DENY",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function sendJson(response, statusCode, value, extraHeaders = {}) {
|
|
33
|
+
response.writeHead(statusCode, {
|
|
34
|
+
...SECURITY_HEADERS,
|
|
35
|
+
"content-type": "application/json; charset=utf-8",
|
|
36
|
+
...extraHeaders,
|
|
37
|
+
});
|
|
38
|
+
response.end(`${JSON.stringify(value)}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isLoopbackHostHeader(value) {
|
|
42
|
+
if (typeof value !== "string") {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const hostname = value.startsWith("[")
|
|
46
|
+
? value.slice(1, value.indexOf("]"))
|
|
47
|
+
: value.split(":", 1)[0];
|
|
48
|
+
return hostname === LOOPBACK_HOST || hostname === "localhost" || hostname === "::1";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasToken(request, token) {
|
|
52
|
+
const authorization = request.headers.authorization;
|
|
53
|
+
if (typeof authorization !== "string" || !authorization.startsWith("Bearer ")) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const supplied = Buffer.from(authorization.slice("Bearer ".length));
|
|
57
|
+
const expected = Buffer.from(token);
|
|
58
|
+
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readJsonBody(request) {
|
|
62
|
+
const chunks = [];
|
|
63
|
+
let bytes = 0;
|
|
64
|
+
for await (const chunk of request) {
|
|
65
|
+
bytes += chunk.length;
|
|
66
|
+
if (bytes > MAX_EVENT_BODY_BYTES) {
|
|
67
|
+
const error = new Error("event body is too large");
|
|
68
|
+
error.statusCode = 413;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
chunks.push(chunk);
|
|
72
|
+
}
|
|
73
|
+
if (bytes === 0) {
|
|
74
|
+
const error = new Error("event body is required");
|
|
75
|
+
error.statusCode = 400;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
80
|
+
} catch {
|
|
81
|
+
const error = new Error("event body must be valid JSON");
|
|
82
|
+
error.statusCode = 400;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function startMonitorServer({
|
|
88
|
+
host = LOOPBACK_HOST,
|
|
89
|
+
port = DEFAULT_PORT,
|
|
90
|
+
env = process.env,
|
|
91
|
+
store = createMonitorStore(),
|
|
92
|
+
token = createRuntimeToken(),
|
|
93
|
+
now = Date.now,
|
|
94
|
+
} = {}) {
|
|
95
|
+
if (host !== LOOPBACK_HOST) {
|
|
96
|
+
throw new Error(`monitor server must bind to ${LOOPBACK_HOST}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const server = createServer(async (request, response) => {
|
|
100
|
+
try {
|
|
101
|
+
if (!isLoopbackHostHeader(request.headers.host)) {
|
|
102
|
+
sendJson(response, 421, { error: "loopback host required" });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const requestUrl = new URL(request.url || "/", `http://${LOOPBACK_HOST}`);
|
|
107
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/health") {
|
|
108
|
+
sendJson(response, 200, { ok: true });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (requestUrl.pathname.startsWith("/api/") && !hasToken(request, token)) {
|
|
113
|
+
sendJson(response, 401, { error: "authorization required" });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/state") {
|
|
118
|
+
sendJson(response, 200, store.getSnapshot());
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/events") {
|
|
123
|
+
const payload = await readJsonBody(request);
|
|
124
|
+
const result = store.ingest(payload, { receivedAtMs: now() });
|
|
125
|
+
sendJson(response, 202, { status: result.status });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const asset = request.method === "GET" ? STATIC_FILES.get(requestUrl.pathname) : null;
|
|
130
|
+
if (asset) {
|
|
131
|
+
const body = await readFile(fileURLToPath(asset.url));
|
|
132
|
+
response.writeHead(200, {
|
|
133
|
+
...SECURITY_HEADERS,
|
|
134
|
+
"content-type": asset.type,
|
|
135
|
+
});
|
|
136
|
+
response.end(body);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
sendJson(response, 404, { error: "not found" });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
sendJson(response, error?.statusCode || 500, {
|
|
143
|
+
error: error?.statusCode ? error.message : "internal server error",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
server.requestTimeout = 5_000;
|
|
149
|
+
server.headersTimeout = 5_000;
|
|
150
|
+
server.keepAliveTimeout = 2_000;
|
|
151
|
+
server.maxHeadersCount = 64;
|
|
152
|
+
|
|
153
|
+
await new Promise((resolve, reject) => {
|
|
154
|
+
server.once("error", reject);
|
|
155
|
+
server.listen(port, host, () => {
|
|
156
|
+
server.off("error", reject);
|
|
157
|
+
resolve();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const address = server.address();
|
|
162
|
+
if (address === null || typeof address === "string") {
|
|
163
|
+
server.close();
|
|
164
|
+
throw new Error("monitor server did not expose a TCP address");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const runtimeInfo = {
|
|
168
|
+
schema_version: RUNTIME_SCHEMA_VERSION,
|
|
169
|
+
host,
|
|
170
|
+
port: address.port,
|
|
171
|
+
token,
|
|
172
|
+
pid: process.pid,
|
|
173
|
+
started_at_ms: now(),
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
await writeRuntimeInfo(runtimeInfo, env);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
await new Promise((resolve) => {
|
|
179
|
+
server.close(() => resolve());
|
|
180
|
+
});
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let closed = false;
|
|
185
|
+
async function close() {
|
|
186
|
+
if (closed) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
closed = true;
|
|
190
|
+
await new Promise((resolve, reject) => {
|
|
191
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
192
|
+
});
|
|
193
|
+
await removeRuntimeInfo(token, env);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
close,
|
|
198
|
+
runtimeInfo,
|
|
199
|
+
server,
|
|
200
|
+
store,
|
|
201
|
+
url: `http://${host}:${address.port}/#token=${encodeURIComponent(token)}`,
|
|
202
|
+
};
|
|
203
|
+
}
|