agent-office-cli 0.0.1

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.
@@ -0,0 +1,152 @@
1
+ const { spawnSync, spawn } = require("node:child_process");
2
+ const crypto = require("node:crypto");
3
+ const pty = require("node-pty");
4
+
5
+ const AGENTOFFICE_TMUX_PREFIX = "agentoffice_";
6
+
7
+ function tmuxPath() {
8
+ return process.env.TMUX_BIN || "tmux";
9
+ }
10
+
11
+ function runTmux(args, options = {}) {
12
+ return spawnSync(tmuxPath(), args, {
13
+ encoding: "utf8",
14
+ ...options
15
+ });
16
+ }
17
+
18
+ function assertTmuxOk(result, action) {
19
+ if (result.status === 0) {
20
+ return;
21
+ }
22
+ const message = (result.stderr || result.stdout || `${action} failed`).trim();
23
+ throw new Error(`tmux ${action} failed: ${message}`);
24
+ }
25
+
26
+ function shellQuote(value) {
27
+ return `'${String(value).replaceAll("'", `'\\''`)}'`;
28
+ }
29
+
30
+ function createTmuxSession({ sessionName, cwd, command, shell }) {
31
+ // Clean env: remove CLAUDECODE to prevent "nested session" detection
32
+ const cleanEnv = { ...process.env };
33
+ delete cleanEnv.CLAUDECODE;
34
+
35
+ const createResult = runTmux(["new-session", "-d", "-s", sessionName, "-c", cwd], {
36
+ env: cleanEnv
37
+ });
38
+ assertTmuxOk(createResult, "new-session");
39
+
40
+ const remainResult = runTmux(["set-option", "-t", sessionName, "remain-on-exit", "on"]);
41
+ assertTmuxOk(remainResult, "set-option remain-on-exit");
42
+
43
+ const launchCommand = `exec ${shell} -lc ${shellQuote(command)}`;
44
+ const sendLiteralResult = runTmux(["send-keys", "-t", `${sessionName}:0.0`, "-l", launchCommand]);
45
+ assertTmuxOk(sendLiteralResult, "send-keys literal");
46
+
47
+ const sendEnterResult = runTmux(["send-keys", "-t", `${sessionName}:0.0`, "Enter"]);
48
+ assertTmuxOk(sendEnterResult, "send-keys enter");
49
+ }
50
+
51
+ function listSessions() {
52
+ const result = runTmux(["list-sessions", "-F", "#{session_name}"]);
53
+ if (result.status !== 0) {
54
+ return [];
55
+ }
56
+ return (result.stdout || "")
57
+ .split("\n")
58
+ .map((line) => line.trim())
59
+ .filter(Boolean);
60
+ }
61
+
62
+ function listAgentOfficeSessions() {
63
+ return listSessions().filter((sessionName) => sessionName.startsWith(AGENTOFFICE_TMUX_PREFIX));
64
+ }
65
+
66
+ function sessionExists(sessionName) {
67
+ const result = runTmux(["has-session", "-t", sessionName]);
68
+ return result.status === 0;
69
+ }
70
+
71
+ function killSession(sessionName) {
72
+ const result = runTmux(["kill-session", "-t", sessionName]);
73
+ if (result.status !== 0) {
74
+ return false;
75
+ }
76
+ return true;
77
+ }
78
+
79
+ function describePane(sessionName) {
80
+ const result = runTmux([
81
+ "list-panes",
82
+ "-t",
83
+ sessionName,
84
+ "-F",
85
+ "#{pane_pid}\t#{pane_dead}\t#{pane_dead_status}\t#{pane_current_command}"
86
+ ]);
87
+
88
+ if (result.status !== 0) {
89
+ return null;
90
+ }
91
+
92
+ const line = (result.stdout || "").trim().split("\n")[0];
93
+ if (!line) {
94
+ return null;
95
+ }
96
+
97
+ const [pidText, deadText, deadStatusText, currentCommand] = line.split("\t");
98
+ return {
99
+ pid: Number(pidText || 0) || null,
100
+ dead: deadText === "1",
101
+ deadStatus: deadStatusText === "" ? null : Number(deadStatusText),
102
+ currentCommand: currentCommand || null
103
+ };
104
+ }
105
+
106
+ function capturePane(sessionName) {
107
+ return new Promise((resolve) => {
108
+ const proc = spawn(tmuxPath(), ["capture-pane", "-p", "-e", "-J", "-t", `${sessionName}:0.0`]);
109
+ let stdout = "";
110
+ proc.stdout.on("data", (chunk) => { stdout += chunk; });
111
+ proc.on("close", (code) => { resolve(code === 0 ? stdout : ""); });
112
+ proc.on("error", () => { resolve(""); });
113
+ });
114
+ }
115
+
116
+ function attachClient(sessionName, { cwd, cols = 120, rows = 32 } = {}) {
117
+ // Create a linked session that shares the same window group but with status bar
118
+ // disabled, so the tmux chrome does not leak into the xterm.js stream.
119
+ const webSession = `${sessionName}_wv_${crypto.randomBytes(3).toString("hex")}`;
120
+
121
+ const proc = pty.spawn(tmuxPath(), [
122
+ "new-session", "-t", sessionName, "-s", webSession,
123
+ ";", "set-option", "status", "off"
124
+ ], {
125
+ name: "xterm-256color",
126
+ cwd: cwd || process.cwd(),
127
+ env: process.env,
128
+ cols,
129
+ rows
130
+ });
131
+
132
+ // Expose the linked session name so callers can clean it up on disconnect.
133
+ proc.webTmuxSession = webSession;
134
+ return proc;
135
+ }
136
+
137
+ function localAttachCommand(sessionName) {
138
+ return `${tmuxPath()} attach-session -t ${sessionName}`;
139
+ }
140
+
141
+ module.exports = {
142
+ AGENTOFFICE_TMUX_PREFIX,
143
+ attachClient,
144
+ capturePane,
145
+ createTmuxSession,
146
+ describePane,
147
+ killSession,
148
+ listAgentOfficeSessions,
149
+ localAttachCommand,
150
+ sessionExists,
151
+ tmuxPath
152
+ };
package/src/server.js ADDED
@@ -0,0 +1,208 @@
1
+ const http = require("node:http");
2
+ const path = require("node:path");
3
+ const express = require("express");
4
+ const { WebSocketServer } = require("ws");
5
+ const { STATIC_DIR } = require("./web");
6
+ const auth = require("./auth");
7
+
8
+ function createAppServer({ host, port, store, ptyManager }) {
9
+ const app = express();
10
+ app.set("trust proxy", true);
11
+ app.use(express.json({ limit: "1mb" }));
12
+
13
+ const AUTH_WHITELIST = new Set([
14
+ "/api/auth/login",
15
+ "/api/auth/check",
16
+ "/api/auth/logout",
17
+ "/login.html",
18
+ "/register.html",
19
+ "/login.css"
20
+ ]);
21
+
22
+ function isWhitelisted(pathname) {
23
+ if (AUTH_WHITELIST.has(pathname)) {
24
+ return true;
25
+ }
26
+ return pathname.startsWith("/api/auth/");
27
+ }
28
+
29
+ function isAuthorizedRequest(req) {
30
+ const ip = req.ip || req.socket?.remoteAddress || "";
31
+ const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
32
+ if (isLocal) {
33
+ return true;
34
+ }
35
+ const cookieToken = auth.getTokenFromCookie(req);
36
+ return Boolean(cookieToken && auth.verifyToken(cookieToken));
37
+ }
38
+
39
+ function sendOfficeShell(res) {
40
+ res.sendFile(path.join(STATIC_DIR, "office.html"));
41
+ }
42
+
43
+ app.use((req, res, next) => {
44
+ if (isWhitelisted(req.path)) {
45
+ return next();
46
+ }
47
+
48
+ if (isAuthorizedRequest(req)) {
49
+ return next();
50
+ }
51
+
52
+ if (req.path.startsWith("/api/")) {
53
+ return res.status(401).json({ error: "unauthorized" });
54
+ }
55
+
56
+ return res.redirect("/login.html");
57
+ });
58
+
59
+ app.post("/api/auth/login", (req, res) => {
60
+ const ip = req.ip || req.connection?.remoteAddress || "";
61
+ const rateCheck = auth.checkRateLimit(ip);
62
+
63
+ if (!rateCheck.allowed) {
64
+ if (rateCheck.locked) {
65
+ return res.status(429).json({
66
+ error: "locked",
67
+ message: `Too many failed attempts. Locked for ${rateCheck.remainingSeconds}s.`,
68
+ remainingSeconds: rateCheck.remainingSeconds
69
+ });
70
+ }
71
+ return res.status(429).json({
72
+ error: "rate_limited",
73
+ message: "Too many attempts. Try again in a minute.",
74
+ remaining: 0
75
+ });
76
+ }
77
+
78
+ const token = String(req.body.token || "").trim();
79
+ if (!auth.verifyToken(token)) {
80
+ auth.recordAttempt(ip, false);
81
+ const afterCheck = auth.checkRateLimit(ip);
82
+ return res.status(401).json({
83
+ error: "invalid_token",
84
+ remaining: afterCheck.remaining
85
+ });
86
+ }
87
+
88
+ auth.recordAttempt(ip, true);
89
+ const secure = req.protocol === "https" || req.get("x-forwarded-proto") === "https";
90
+ auth.setAuthCookie(res, token, secure);
91
+ return res.json({ ok: true });
92
+ });
93
+
94
+ app.post("/api/auth/logout", (_req, res) => {
95
+ auth.clearAuthCookie(res);
96
+ res.json({ ok: true });
97
+ });
98
+
99
+ app.get("/api/auth/check", (req, res) => {
100
+ const cookieToken = auth.getTokenFromCookie(req);
101
+ const authenticated = cookieToken ? auth.verifyToken(cookieToken) : false;
102
+ res.json({ authenticated });
103
+ });
104
+
105
+ app.get("/", (_req, res) => {
106
+ sendOfficeShell(res);
107
+ });
108
+
109
+ app.get("/index.html", (_req, res) => {
110
+ sendOfficeShell(res);
111
+ });
112
+
113
+ app.use(express.static(STATIC_DIR, { index: false }));
114
+
115
+ app.get("/api/health", (_req, res) => {
116
+ res.json({ ok: true });
117
+ });
118
+
119
+ app.get("/api/sessions", (_req, res) => {
120
+ res.json({ sessions: store.listSessionSummaries() });
121
+ });
122
+
123
+ app.get("/api/sessions/:sessionId", (req, res) => {
124
+ const session = store.getSession(req.params.sessionId);
125
+ if (!session) {
126
+ res.status(404).json({ error: "session_not_found" });
127
+ return;
128
+ }
129
+ res.json(session);
130
+ });
131
+
132
+ app.post("/api/sessions/launch", (req, res) => {
133
+ const command = String(req.body.command || "").trim();
134
+ if (!command) {
135
+ res.status(400).json({ error: "missing_command" });
136
+ return;
137
+ }
138
+ const session = ptyManager.createManagedSession({
139
+ provider: req.body.provider || "generic",
140
+ title: req.body.title || command,
141
+ cwd: req.body.cwd || process.cwd(),
142
+ command,
143
+ transport: req.body.transport || null
144
+ });
145
+ res.json({ session });
146
+ });
147
+
148
+ app.post("/api/providers/claude/hook", (req, res) => {
149
+ const session = ptyManager.ingestClaudeHook(req.body || {});
150
+ res.json({ ok: true, sessionId: session?.sessionId ?? null });
151
+ });
152
+
153
+ app.get("*", (_req, res) => {
154
+ sendOfficeShell(res);
155
+ });
156
+
157
+ const server = http.createServer(app);
158
+ const wss = new WebSocketServer({ noServer: true });
159
+
160
+ server.on("upgrade", (request, socket, head) => {
161
+ const url = new URL(request.url, `http://${request.headers.host}`);
162
+ const pathname = url.pathname;
163
+
164
+ if (!pathname.startsWith("/ws/")) {
165
+ socket.destroy();
166
+ return;
167
+ }
168
+
169
+ const remoteIp = request.socket?.remoteAddress || "";
170
+ const isLocal = remoteIp === "127.0.0.1" || remoteIp === "::1" || remoteIp === "::ffff:127.0.0.1";
171
+ if (!isLocal) {
172
+ const cookieToken = auth.getTokenFromCookie(request);
173
+ if (!cookieToken || !auth.verifyToken(cookieToken)) {
174
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
175
+ socket.destroy();
176
+ return;
177
+ }
178
+ }
179
+
180
+ wss.handleUpgrade(request, socket, head, (ws) => {
181
+ ws.path = pathname;
182
+ wss.emit("connection", ws, request);
183
+ });
184
+ });
185
+
186
+ wss.on("connection", (ws) => {
187
+ if (ws.path === "/ws/events") {
188
+ ptyManager.registerEventsSocket(ws);
189
+ return;
190
+ }
191
+ if (ws.path.startsWith("/ws/terminal/")) {
192
+ const sessionId = decodeURIComponent(ws.path.split("/").pop());
193
+ ptyManager.registerTerminalSocket(sessionId, ws);
194
+ return;
195
+ }
196
+ ws.close();
197
+ });
198
+
199
+ server.listen(port, host, () => {
200
+ console.log(`AgentOffice listening on http://${host}:${port}`);
201
+ });
202
+
203
+ return server;
204
+ }
205
+
206
+ module.exports = {
207
+ createAppServer
208
+ };
package/src/tunnel.js ADDED
@@ -0,0 +1,224 @@
1
+ const { WebSocket } = require("ws");
2
+ const { toSessionSummary } = require("./core");
3
+
4
+ const RECONNECT_BASE_MS = 1000;
5
+ const RECONNECT_MAX_MS = 30000;
6
+
7
+ function createTunnelClient({ key, relayUrl, localServerUrl }) {
8
+ let ws = null;
9
+ let reconnectDelay = RECONNECT_BASE_MS;
10
+ let stopped = false;
11
+ let authenticated = false;
12
+ let pendingStatusSummary = [];
13
+
14
+ function flushStatusSummary() {
15
+ if (!authenticated || !ws || ws.readyState !== WebSocket.OPEN) {
16
+ return;
17
+ }
18
+ ws.send(JSON.stringify({
19
+ type: "status:summary",
20
+ sessions: pendingStatusSummary
21
+ }));
22
+ }
23
+
24
+ function connect() {
25
+ if (stopped) {
26
+ return;
27
+ }
28
+
29
+ authenticated = false;
30
+ const url = `${relayUrl.replace(/^http/, "ws")}/upstream`;
31
+ ws = new WebSocket(url);
32
+
33
+ ws.on("open", () => {
34
+ reconnectDelay = RECONNECT_BASE_MS;
35
+ console.log("[tunnel] connected to relay, authenticating...");
36
+ ws.send(JSON.stringify({ type: "auth", key }));
37
+ });
38
+
39
+ ws.on("message", async (raw) => {
40
+ try {
41
+ const str = String(raw);
42
+
43
+ // Fast path: WS data forwarding uses "W:${connId}:${data}" prefix (no JSON parse)
44
+ if (str.startsWith("W:")) {
45
+ const connId = str.slice(2, 16);
46
+ const data = str.slice(17);
47
+ const localWs = localWsConnections.get(connId);
48
+ if (localWs && localWs.readyState === WebSocket.OPEN) {
49
+ localWs.send(data);
50
+ }
51
+ return;
52
+ }
53
+
54
+ const msg = JSON.parse(str);
55
+
56
+ if (msg.type === "auth:ok") {
57
+ authenticated = true;
58
+ console.log(`[tunnel] authenticated with relay: ${relayUrl} (userId=${msg.userId})`);
59
+ flushStatusSummary();
60
+ return;
61
+ }
62
+ if (msg.type === "auth:error") {
63
+ console.error(`[tunnel] authentication failed: ${msg.error || "invalid key"}`);
64
+ stopped = true;
65
+ ws.close();
66
+ return;
67
+ }
68
+
69
+ if (!authenticated) {
70
+ return;
71
+ }
72
+
73
+ await handleRelayMessage(msg);
74
+ } catch (err) {
75
+ console.error(`[tunnel] message error: ${err.message}`);
76
+ }
77
+ });
78
+
79
+ ws.on("close", (code) => {
80
+ if (stopped) {
81
+ return;
82
+ }
83
+ if (code === 4401) {
84
+ console.error("[tunnel] authentication rejected by relay. Not reconnecting.");
85
+ stopped = true;
86
+ return;
87
+ }
88
+ console.log(`[tunnel] disconnected (${code}). Reconnecting in ${reconnectDelay}ms...`);
89
+ setTimeout(() => {
90
+ reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
91
+ connect();
92
+ }, reconnectDelay);
93
+ });
94
+
95
+ ws.on("error", (err) => {
96
+ console.error(`[tunnel] ws error: ${err.message}`);
97
+ });
98
+ }
99
+
100
+ async function handleRelayMessage(msg) {
101
+ if (msg.type === "http:request") {
102
+ await handleHttpRequest(msg);
103
+ return;
104
+ }
105
+ if (msg.type === "ws:open") {
106
+ handleWsOpen(msg);
107
+ return;
108
+ }
109
+ if (msg.type === "ws:close") {
110
+ handleWsClose(msg);
111
+ }
112
+ }
113
+
114
+ async function handleHttpRequest(msg) {
115
+ try {
116
+ const fetchUrl = `${localServerUrl}${msg.path}`;
117
+ const fetchOptions = {
118
+ method: msg.method || "GET",
119
+ headers: { ...msg.headers, host: new URL(localServerUrl).host }
120
+ };
121
+ if (msg.body && msg.method !== "GET" && msg.method !== "HEAD") {
122
+ fetchOptions.body = msg.body;
123
+ fetchOptions.headers["content-type"] = fetchOptions.headers["content-type"] || "application/json";
124
+ }
125
+
126
+ const response = await fetch(fetchUrl, fetchOptions);
127
+ const body = await response.text();
128
+ const responseHeaders = {};
129
+ for (const [keyName, value] of response.headers) {
130
+ if (!["transfer-encoding", "connection", "content-encoding"].includes(keyName.toLowerCase())) {
131
+ responseHeaders[keyName] = value;
132
+ }
133
+ }
134
+
135
+ sendToRelay({
136
+ type: "http:response",
137
+ reqId: msg.reqId,
138
+ status: response.status,
139
+ headers: responseHeaders,
140
+ body
141
+ });
142
+ } catch (err) {
143
+ sendToRelay({
144
+ type: "http:response",
145
+ reqId: msg.reqId,
146
+ status: 502,
147
+ headers: {},
148
+ body: JSON.stringify({ error: err.message })
149
+ });
150
+ }
151
+ }
152
+
153
+ const localWsConnections = new Map();
154
+
155
+ function handleWsOpen(msg) {
156
+ const connId = msg.connId;
157
+ const localWsUrl = `${localServerUrl.replace(/^http/, "ws")}${msg.path}`;
158
+ const localWs = new WebSocket(localWsUrl);
159
+
160
+ localWsConnections.set(connId, localWs);
161
+
162
+ localWs.on("message", (data) => {
163
+ if (ws && ws.readyState === WebSocket.OPEN) {
164
+ // Fast path: prefix with connId, no JSON wrapping
165
+ ws.send(`W:${connId}:${String(data)}`);
166
+ }
167
+ });
168
+
169
+ localWs.on("close", () => {
170
+ localWsConnections.delete(connId);
171
+ sendToRelay({
172
+ type: "ws:close",
173
+ connId
174
+ });
175
+ });
176
+
177
+ localWs.on("error", () => {
178
+ localWsConnections.delete(connId);
179
+ });
180
+ }
181
+
182
+ function handleWsClose(msg) {
183
+ const localWs = localWsConnections.get(msg.connId);
184
+ if (localWs) {
185
+ localWsConnections.delete(msg.connId);
186
+ localWs.close();
187
+ }
188
+ }
189
+
190
+ function sendToRelay(payload) {
191
+ if (ws && ws.readyState === WebSocket.OPEN) {
192
+ ws.send(JSON.stringify(payload));
193
+ }
194
+ }
195
+
196
+ function sendStatusSummary(sessions) {
197
+ pendingStatusSummary = sessions
198
+ .map((session) => toSessionSummary(session))
199
+ .filter(Boolean);
200
+ flushStatusSummary();
201
+ }
202
+
203
+ function stop() {
204
+ stopped = true;
205
+ for (const localWs of localWsConnections.values()) {
206
+ localWs.close();
207
+ }
208
+ localWsConnections.clear();
209
+ if (ws) {
210
+ ws.close();
211
+ }
212
+ }
213
+
214
+ connect();
215
+
216
+ return {
217
+ sendStatusSummary,
218
+ stop
219
+ };
220
+ }
221
+
222
+ module.exports = {
223
+ createTunnelClient
224
+ };
@@ -0,0 +1,7 @@
1
+ const path = require("node:path");
2
+
3
+ const STATIC_DIR = path.join(__dirname, "public");
4
+
5
+ module.exports = {
6
+ STATIC_DIR
7
+ };