@ours.network/codex 1.1.1 → 1.2.0-nightly.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.
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.json +2 -2
- package/README.md +33 -5
- package/bin/container-file-transfer.mjs +147 -0
- package/bin/container-launch.mjs +40 -0
- package/bin/network-watch.mjs +8 -0
- package/bin/proxy.mjs +42 -21
- package/dist/hooks-runner.mjs +16649 -0
- package/dist/monitor-mcp.mjs +154 -24
- package/dist/network-proxy.mjs +16782 -0
- package/dist/network-watch.mjs +391 -0
- package/dist/profile.mjs +198 -0
- package/hooks/hooks.json +3 -3
- package/package.json +3 -4
- package/skills/ours/SKILL.md +2 -1
- package/src/hooks/runner.mjs +54 -7
- package/src/launcher.mjs +49 -15
- package/src/monitor-mcp.mjs +19 -6
- package/src/network-proxy.mjs +308 -0
- package/src/network-watch.mjs +45 -0
- package/src/profile.mjs +17 -2
- package/src/watcher.mjs +29 -3
package/src/hooks/runner.mjs
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { readFile } from 'node:fs/promises';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { dirname, join, resolve } from 'node:path';
|
|
5
6
|
import { pathToFileURL } from 'node:url';
|
|
6
7
|
import { sendControlCommand } from '../control-server.mjs';
|
|
7
8
|
import { resolveDaemonProfile } from '../profile.mjs';
|
|
9
|
+
import { createHostSessionFactory } from '../network-proxy.mjs';
|
|
10
|
+
import { attachOursClient } from '@ours.network/sdk';
|
|
11
|
+
import { hostProfileFromEnv } from '../../../core/src/host-client/index.ts';
|
|
8
12
|
|
|
9
13
|
async function defaultFindPin(cwd) {
|
|
10
14
|
let dir = resolve(cwd || process.cwd());
|
|
@@ -31,11 +35,13 @@ const safeUnread = (value) => (Array.isArray(value?.identities) ? value.identiti
|
|
|
31
35
|
return [{ name: entry.name, count, files, recent }];
|
|
32
36
|
});
|
|
33
37
|
|
|
34
|
-
async function applicationIdentityNames(env,
|
|
38
|
+
async function applicationIdentityNames(env, selection) {
|
|
35
39
|
const path = env.OURS_MCP_CONFIG || join(homedir(), '.ours-mcp', 'config.json');
|
|
36
40
|
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
37
41
|
if (value?.version !== 1 || !value.daemons || typeof value.daemons !== 'object') return new Set();
|
|
38
|
-
const identities =
|
|
42
|
+
const identities = selection.instanceId
|
|
43
|
+
? value.instances?.[selection.instanceId]?.identities
|
|
44
|
+
: value.daemons[resolve(selection.stateDir)]?.identities;
|
|
39
45
|
return new Set(Array.isArray(identities) ? identities.filter((name) => typeof name === 'string') : []);
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -54,7 +60,16 @@ function renderContext(unread, pin) {
|
|
|
54
60
|
return lines.join('\n');
|
|
55
61
|
}
|
|
56
62
|
|
|
57
|
-
|
|
63
|
+
function applicationIdentitiesFromResource(value) {
|
|
64
|
+
const text = value?.contents?.find((item) => item?.uri === 'ours://application-identities')?.text;
|
|
65
|
+
const parsed = JSON.parse(text);
|
|
66
|
+
if (!Array.isArray(parsed?.identities) || parsed.identities.some((name) => typeof name !== 'string')) {
|
|
67
|
+
throw new Error('invalid application identity resource');
|
|
68
|
+
}
|
|
69
|
+
return new Set(parsed.identities);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function handleHook(payload, { env = process.env, fetch: fetchImpl = globalThis.fetch, send = sendControlCommand, findPin = defaultFindPin, profileResolver = resolveDaemonProfile, clientFactory = attachOursClient, networkSessionFactory = createHostSessionFactory } = {}) {
|
|
58
73
|
try {
|
|
59
74
|
const event = payload?.hook_event_name;
|
|
60
75
|
const socket = env.OURS_CODEX_CONTROL_SOCKET;
|
|
@@ -75,21 +90,53 @@ export async function handleHook(payload, { env = process.env, fetch: fetchImpl
|
|
|
75
90
|
let port = env.OURS_PORT || '3050';
|
|
76
91
|
let selectedStateDir = resolve(env.OURS_STATE_DIR || join(homedir(), '.ours'));
|
|
77
92
|
let selectedToken = env.OURS_API_TOKEN || '';
|
|
93
|
+
let hostProfile;
|
|
94
|
+
try { hostProfile = hostProfileFromEnv(env); }
|
|
95
|
+
catch { return { continue: true }; }
|
|
96
|
+
if (hostProfile) {
|
|
97
|
+
const nativeSessionId = payload.session_id;
|
|
98
|
+
if (typeof nativeSessionId !== 'string' || !nativeSessionId) return { continue: true };
|
|
99
|
+
const appPath = env.OURS_MCP_CONFIG || join(env.HOME || homedir(), '.ours-mcp', 'config.json');
|
|
100
|
+
let session;
|
|
101
|
+
try {
|
|
102
|
+
const factory = networkSessionFactory({ profile: hostProfile, hostRecordRoot: dirname(appPath), send: async () => {} });
|
|
103
|
+
session = await factory(nativeSessionId);
|
|
104
|
+
const resource = await session.request({ method: 'resources/read', params: { uri: 'ours://application-identities' } });
|
|
105
|
+
const visible = applicationIdentitiesFromResource(resource);
|
|
106
|
+
const unread = safeUnread(await session.fileClient.unread()).filter((entry) => visible.has(entry.name));
|
|
107
|
+
const pin = await findPin(payload.cwd || process.cwd());
|
|
108
|
+
const context = renderContext(unread, pin);
|
|
109
|
+
return context ? { continue: true, hookSpecificOutput: { hookEventName: event, additionalContext: context } } : { continue: true };
|
|
110
|
+
} finally { await session?.close(); }
|
|
111
|
+
}
|
|
78
112
|
// Standard Codex does not pass through ours-codex's resolved environment.
|
|
79
113
|
// Resolve the same coherent SDK selection here so SessionStart and
|
|
80
114
|
// UserPromptSubmit inspect the same daemon as the stdio proxy.
|
|
81
115
|
try {
|
|
82
|
-
const selected = await
|
|
116
|
+
const selected = await profileResolver({ env, fetch: fetchImpl });
|
|
117
|
+
if (selected.profile) {
|
|
118
|
+
let client;
|
|
119
|
+
try {
|
|
120
|
+
client = await clientFactory({ ...selected.profile, sessionMode: 'external', leaseToken: `codex-hook-${process.pid}`, env: {} });
|
|
121
|
+
const visible = await applicationIdentityNames(env, { instanceId: selected.profile.expectedInstanceId });
|
|
122
|
+
const unread = safeUnread(await client.unread()).filter((entry) => visible.has(entry.name));
|
|
123
|
+
const pin = await findPin(payload.cwd || process.cwd());
|
|
124
|
+
const context = renderContext(unread, pin);
|
|
125
|
+
return context ? { continue: true, hookSpecificOutput: { hookEventName: event, additionalContext: context } } : { continue: true };
|
|
126
|
+
} finally { await client?.close(); }
|
|
127
|
+
}
|
|
83
128
|
port = String(selected.port);
|
|
84
129
|
selectedToken = selected.token || '';
|
|
85
130
|
selectedStateDir = resolve(selected.stateDir);
|
|
86
|
-
} catch {
|
|
131
|
+
} catch {
|
|
132
|
+
/* proxy/launcher owns legacy diagnostics; hooks emit a benign no-op */
|
|
133
|
+
}
|
|
87
134
|
const headers = selectedToken ? { 'x-ours-api-token': selectedToken } : {};
|
|
88
135
|
let unread = [];
|
|
89
136
|
try {
|
|
90
137
|
const response = await fetchImpl(`http://127.0.0.1:${port}/unread`, { headers, signal: AbortSignal.timeout(1500) });
|
|
91
138
|
if (response.ok) {
|
|
92
|
-
const visible = await applicationIdentityNames(env, selectedStateDir);
|
|
139
|
+
const visible = await applicationIdentityNames(env, { stateDir: selectedStateDir });
|
|
93
140
|
unread = safeUnread(await response.json()).filter((entry) => visible.has(entry.name));
|
|
94
141
|
}
|
|
95
142
|
} catch { /* daemon diagnostics belong to launcher/proxy */ }
|
|
@@ -102,7 +149,7 @@ export async function handleHook(payload, { env = process.env, fetch: fetchImpl
|
|
|
102
149
|
|
|
103
150
|
async function main() {
|
|
104
151
|
let payload = {};
|
|
105
|
-
try { payload = JSON.parse(
|
|
152
|
+
try { payload = JSON.parse(readFileSync(0, 'utf8')); } catch { /* noop */ }
|
|
106
153
|
process.stdout.write(JSON.stringify(await handleHook(payload)));
|
|
107
154
|
}
|
|
108
155
|
|
package/src/launcher.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { dirname, join } from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { randomBytes } from 'node:crypto';
|
|
8
8
|
import { once } from 'node:events';
|
|
9
|
-
import { resolveDaemonProfile } from '
|
|
9
|
+
import { resolveDaemonProfile } from '../dist/profile.mjs';
|
|
10
10
|
import { connectAppServer } from './app-server-client.mjs';
|
|
11
11
|
import { AtomicStateStore, ControlServer } from './control-server.mjs';
|
|
12
12
|
import { MonitorWatcher } from './watcher.mjs';
|
|
@@ -23,19 +23,23 @@ export const remoteTuiArgs = (url, codexArgs) => ['--remote', url, ...codexArgs]
|
|
|
23
23
|
export function launcherEnvironment(env, profile, control) {
|
|
24
24
|
const out = {
|
|
25
25
|
...env,
|
|
26
|
-
OURS_PORT: String(profile.port),
|
|
27
26
|
OURS_CONFIG: profile.configPath,
|
|
28
|
-
OURS_AUTOSTART: '0',
|
|
29
27
|
OURS_CODEX_CONTROL_SOCKET: control.socketPath,
|
|
30
28
|
OURS_CODEX_CAPABILITY: control.capability,
|
|
31
29
|
OURS_CODEX_LIVE: '1',
|
|
32
30
|
// One stable owner pid for the MCP proxy, hooks, and launcher's own cleanup
|
|
33
31
|
// call. The bin shim preserves an explicit value instead of replacing it
|
|
34
32
|
// with whichever child happened to spawn it.
|
|
35
|
-
OURS_CLIENT_PID: String(process.pid),
|
|
36
33
|
};
|
|
37
|
-
if (profile.
|
|
38
|
-
|
|
34
|
+
if (profile.profile) {
|
|
35
|
+
for (const key of ['OURS_PORT', 'OURS_API_TOKEN', 'OURS_STATE_DIR', 'OURS_CLIENT_PID', 'OURS_AUTOSTART', 'OURS_DAEMON_ID']) delete out[key];
|
|
36
|
+
} else {
|
|
37
|
+
out.OURS_PORT = String(profile.port);
|
|
38
|
+
out.OURS_AUTOSTART = '0';
|
|
39
|
+
out.OURS_CLIENT_PID = String(process.pid);
|
|
40
|
+
if (profile.token) out.OURS_API_TOKEN = profile.token;
|
|
41
|
+
else delete out.OURS_API_TOKEN;
|
|
42
|
+
}
|
|
39
43
|
return out;
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -53,6 +57,12 @@ export function sessionRegistrationFromNotification(message, cwd) {
|
|
|
53
57
|
: null;
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
export function sessionEndPayload(profile, state) {
|
|
61
|
+
return profile.profile && typeof state?.threadId === 'string' && state.threadId
|
|
62
|
+
? `${JSON.stringify({ session_id: state.threadId })}\n`
|
|
63
|
+
: null;
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
async function freePort() {
|
|
57
67
|
const server = createServer();
|
|
58
68
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
@@ -75,6 +85,16 @@ function waitExit(child) {
|
|
|
75
85
|
return once(child, 'exit').then(([code, signal]) => ({ code, signal }));
|
|
76
86
|
}
|
|
77
87
|
|
|
88
|
+
function waitCleanupExit(child) {
|
|
89
|
+
if (child.exitCode != null || child.signalCode != null) return Promise.resolve({ kind: 'exit', code: child.exitCode, signal: child.signalCode });
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
let settled = false;
|
|
92
|
+
const finish = (outcome) => { if (!settled) { settled = true; resolve(outcome); } };
|
|
93
|
+
child.once('error', (error) => finish({ kind: 'error', error }));
|
|
94
|
+
child.once('exit', (code, signal) => finish({ kind: 'exit', code, signal }));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
78
98
|
export async function runLauncher({
|
|
79
99
|
argv = process.argv.slice(2), env = process.env, platform = process.platform,
|
|
80
100
|
spawn = nodeSpawn, fetch: fetchImpl = globalThis.fetch,
|
|
@@ -96,23 +116,37 @@ export async function runLauncher({
|
|
|
96
116
|
let watcher;
|
|
97
117
|
const sessionEndShim = join(dirname(dirname(fileURLToPath(import.meta.url))), 'bin', 'proxy.mjs');
|
|
98
118
|
const cleanup = async () => {
|
|
99
|
-
watcher?.stop();
|
|
119
|
+
await watcher?.stop();
|
|
120
|
+
const retainedState = control?.state;
|
|
100
121
|
await control?.close().catch(() => {});
|
|
101
122
|
client?.close();
|
|
102
123
|
// The TUI exit is an authoritative normal session end. Run the same lease
|
|
103
124
|
// DELETE seam as the native hook while the shared daemon is still reachable;
|
|
104
125
|
// duplicate hook+launcher cleanup is intentionally idempotent.
|
|
126
|
+
const input = sessionEndPayload(profile, retainedState);
|
|
127
|
+
if (profile.profile && input === null) {
|
|
128
|
+
if (tui && tui.exitCode == null) tui.kill('SIGTERM');
|
|
129
|
+
if (appServer && appServer.exitCode == null) appServer.kill('SIGTERM');
|
|
130
|
+
await rm(runtimeDir, { recursive: true, force: true });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
105
133
|
const end = spawn(process.execPath, [sessionEndShim, 'session-end'], {
|
|
106
134
|
env: processEnvs.appServer,
|
|
107
|
-
stdio: 'ignore',
|
|
135
|
+
stdio: input === null ? 'ignore' : ['pipe', 'ignore', 'inherit'],
|
|
108
136
|
});
|
|
109
|
-
|
|
137
|
+
if (input !== null) end.stdin?.end(input);
|
|
110
138
|
let cleanupDeadline;
|
|
111
|
-
await Promise.race([
|
|
112
|
-
|
|
113
|
-
new Promise((resolve) => { cleanupDeadline = setTimeout(resolve, 30_000); }),
|
|
114
|
-
]).
|
|
115
|
-
if (end.exitCode == null) end.kill('SIGTERM');
|
|
139
|
+
const cleanupOutcome = await Promise.race([
|
|
140
|
+
waitCleanupExit(end),
|
|
141
|
+
new Promise((resolve) => { cleanupDeadline = setTimeout(() => resolve({ kind: 'timeout' }), 30_000); }),
|
|
142
|
+
]).finally(() => clearTimeout(cleanupDeadline));
|
|
143
|
+
if (end.exitCode == null && end.signalCode == null) end.kill('SIGTERM');
|
|
144
|
+
let cleanupFailure;
|
|
145
|
+
if (cleanupOutcome.kind === 'error') cleanupFailure = `spawn error: ${cleanupOutcome.error.message}`;
|
|
146
|
+
else if (cleanupOutcome.kind === 'timeout') cleanupFailure = 'timed out';
|
|
147
|
+
else if (cleanupOutcome.signal) cleanupFailure = `signal ${cleanupOutcome.signal}`;
|
|
148
|
+
else if ((cleanupOutcome.code ?? 0) !== 0) cleanupFailure = `exit ${cleanupOutcome.code}`;
|
|
149
|
+
if (cleanupFailure) process.stderr.write(`ours: SessionEnd cleanup failed: ${cleanupFailure}\n`);
|
|
116
150
|
if (tui && tui.exitCode == null) tui.kill('SIGTERM');
|
|
117
151
|
if (appServer && appServer.exitCode == null) appServer.kill('SIGTERM');
|
|
118
152
|
await rm(runtimeDir, { recursive: true, force: true });
|
|
@@ -129,7 +163,7 @@ export async function runLauncher({
|
|
|
129
163
|
throw new Error(`ours monitor cannot answer ${message.method}`);
|
|
130
164
|
});
|
|
131
165
|
watcher = new MonitorWatcher({
|
|
132
|
-
baseUrl: profile.baseUrl, token: profile.token, fetch: fetchImpl, appServer: client,
|
|
166
|
+
baseUrl: profile.baseUrl, token: profile.token, profile: profile.profile, fetch: fetchImpl, appServer: client,
|
|
133
167
|
stateStore: new AtomicStateStore(join(runtimeDir, 'cursor.json')),
|
|
134
168
|
});
|
|
135
169
|
control = new ControlServer({
|
package/src/monitor-mcp.mjs
CHANGED
|
@@ -2,10 +2,13 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { z } from 'zod';
|
|
7
|
+
import { hostProfileFromEnv } from '../../core/src/host-client/index.ts';
|
|
6
8
|
import { sendControlCommand } from './control-server.mjs';
|
|
7
9
|
|
|
8
10
|
const require = createRequire(import.meta.url);
|
|
11
|
+
const networkWatchPath = fileURLToPath(new URL('../bin/network-watch.mjs', import.meta.url));
|
|
9
12
|
export const monitorToolNames = ['arm_monitor', 'foreground_monitor', 'disarm_monitor', 'monitor_status'];
|
|
10
13
|
const content = (text, isError = false) => ({ content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) });
|
|
11
14
|
|
|
@@ -20,7 +23,16 @@ const foregroundOffer = (identity) =>
|
|
|
20
23
|
`Only after an explicit yes, call get_messages once to drain existing unread mail, then ` +
|
|
21
24
|
`call foreground_monitor({ identity: ${JSON.stringify(identity)} }).`;
|
|
22
25
|
|
|
23
|
-
export function foregroundWatchProcess(identity) {
|
|
26
|
+
export function foregroundWatchProcess(identity, nativeSessionId, env = process.env) {
|
|
27
|
+
if (hostProfileFromEnv(env)) {
|
|
28
|
+
if (typeof nativeSessionId !== 'string' || !nativeSessionId.trim()) {
|
|
29
|
+
throw new Error('Native session metadata is required for host-profile monitoring.');
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
command: process.execPath,
|
|
33
|
+
args: [networkWatchPath, identity, nativeSessionId],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
24
36
|
try {
|
|
25
37
|
const cliPath = require.resolve('@ours.network/mcp/dist/cli.js');
|
|
26
38
|
return { command: process.execPath, args: [cliPath, 'watch', identity] };
|
|
@@ -48,7 +60,7 @@ export async function handleMonitorCommand(command, args = {}, { env = process.e
|
|
|
48
60
|
: `Live ours monitor is available but not armed${state.boundIdentity ? `; current binding is "${state.boundIdentity}"` : '; bind an identity first'}.` };
|
|
49
61
|
}
|
|
50
62
|
|
|
51
|
-
export function waitForForegroundMail(identity, {
|
|
63
|
+
export function waitForForegroundMail(identity, nativeSessionId, {
|
|
52
64
|
env = process.env,
|
|
53
65
|
spawnImpl = spawn,
|
|
54
66
|
signal,
|
|
@@ -57,16 +69,17 @@ export function waitForForegroundMail(identity, {
|
|
|
57
69
|
if (typeof identity !== 'string' || !identity.trim()) return Promise.reject(new Error('identity is required'));
|
|
58
70
|
|
|
59
71
|
return new Promise((resolve, reject) => {
|
|
60
|
-
const invocation = commandFor(identity);
|
|
72
|
+
const invocation = commandFor(identity, nativeSessionId, env);
|
|
61
73
|
const child = spawnImpl(invocation.command, invocation.args, {
|
|
62
|
-
env,
|
|
63
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
74
|
+
env: invocation.env ?? env,
|
|
75
|
+
stdio: [invocation.stdin ?? 'ignore', 'pipe', 'pipe'],
|
|
64
76
|
});
|
|
65
77
|
let stdout = '';
|
|
66
78
|
let stderr = '';
|
|
67
79
|
let settled = false;
|
|
68
80
|
|
|
69
81
|
const stop = () => {
|
|
82
|
+
if (invocation.stdin === 'pipe') child.stdin?.end();
|
|
70
83
|
if (!child.killed) child.kill('SIGTERM');
|
|
71
84
|
};
|
|
72
85
|
const finish = (fn, value) => {
|
|
@@ -109,7 +122,7 @@ export function createMonitorMcpServer(deps = {}) {
|
|
|
109
122
|
identity: z.string().min(1).describe('The already-bound ours identity to monitor.'),
|
|
110
123
|
}, async ({ identity }, extra) => {
|
|
111
124
|
try {
|
|
112
|
-
const event = await waitForForegroundMail(identity, { ...deps, signal: extra?.signal });
|
|
125
|
+
const event = await waitForForegroundMail(identity, extra?._meta?.threadId, { ...deps, signal: extra?.signal });
|
|
113
126
|
return content(`Foreground ours monitor received a body-free arrival event: ${event}\nCall get_messages now. After handling the mail, call foreground_monitor again without asking if monitoring should remain armed.`);
|
|
114
127
|
} catch (error) {
|
|
115
128
|
if (extra?.signal?.aborted) return content('Foreground ours monitor stopped. It is no longer armed.');
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
|
+
import { StreamableHTTPClientTransport, StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
+
import { ErrorCode, McpError, ResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import {
|
|
11
|
+
completeHostFileCall,
|
|
12
|
+
endNativeSessionAtRoot,
|
|
13
|
+
hostProfileFromEnv,
|
|
14
|
+
nativeClientForAtRoot,
|
|
15
|
+
nativeSessionRecordPath,
|
|
16
|
+
prepareHostFileCall,
|
|
17
|
+
} from '../../core/src/host-client/index.ts';
|
|
18
|
+
|
|
19
|
+
class NetworkTransportError extends Error {
|
|
20
|
+
constructor(cause) {
|
|
21
|
+
super(`Network transport failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
22
|
+
this.name = 'NetworkTransportError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isMcpSessionLoss(error) {
|
|
27
|
+
return error instanceof NetworkTransportError
|
|
28
|
+
|| error instanceof StreamableHTTPError
|
|
29
|
+
|| (error instanceof McpError && error.code === ErrorCode.ConnectionClosed);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function threadSelector(frame) {
|
|
33
|
+
const value = frame?.params?._meta?.threadId;
|
|
34
|
+
if (typeof value !== 'string' || !value || value.trim() !== value) {
|
|
35
|
+
throw new Error('Native session metadata is missing or invalid for this host-profile tool call.');
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function defaultSessionSelector(frame) {
|
|
41
|
+
return frame.method === 'tools/call' ? threadSelector(frame) : '__discovery__';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createNetworkBridge({ sessionFactory, send, selectorFor = defaultSessionSelector }) {
|
|
45
|
+
const sessions = new Map();
|
|
46
|
+
const inFlight = new Map();
|
|
47
|
+
const sessionFor = async (selector) => {
|
|
48
|
+
let promise = sessions.get(selector);
|
|
49
|
+
if (!promise) {
|
|
50
|
+
promise = Promise.resolve(sessionFactory(selector, {
|
|
51
|
+
onClose: () => { if (sessions.get(selector) === promise) sessions.delete(selector); },
|
|
52
|
+
}));
|
|
53
|
+
sessions.set(selector, promise);
|
|
54
|
+
promise.catch(() => { if (sessions.get(selector) === promise) sessions.delete(selector); });
|
|
55
|
+
}
|
|
56
|
+
return promise;
|
|
57
|
+
};
|
|
58
|
+
const evictSession = async (selector, session) => {
|
|
59
|
+
const cached = sessions.get(selector);
|
|
60
|
+
if (cached && await cached.catch(() => undefined) === session && sessions.get(selector) === cached) {
|
|
61
|
+
sessions.delete(selector);
|
|
62
|
+
}
|
|
63
|
+
await session.close().catch(() => {});
|
|
64
|
+
};
|
|
65
|
+
const reply = (id, value, error = false) => send(error
|
|
66
|
+
? { jsonrpc: '2.0', id, error: { code: -32000, message: value instanceof Error ? value.message : String(value) } }
|
|
67
|
+
: { jsonrpc: '2.0', id, result: value });
|
|
68
|
+
const handle = async (frame) => {
|
|
69
|
+
if (!frame || typeof frame !== 'object' || Array.isArray(frame)) return;
|
|
70
|
+
if (frame.method === 'notifications/initialized') return;
|
|
71
|
+
if (frame.method === 'notifications/cancelled') {
|
|
72
|
+
inFlight.get(frame.params?.requestId)?.abort(frame.params?.reason);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (frame.id === undefined || frame.id === null) {
|
|
76
|
+
const session = await sessionFor(selectorFor(frame));
|
|
77
|
+
await session.notification(frame);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
inFlight.set(frame.id, controller);
|
|
82
|
+
try {
|
|
83
|
+
const selector = selectorFor(frame);
|
|
84
|
+
const session = await sessionFor(selector);
|
|
85
|
+
controller.signal.throwIfAborted();
|
|
86
|
+
if (frame.method === 'initialize') {
|
|
87
|
+
await reply(frame.id, session.initializeResult);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const requestFrame = frame.method === 'tools/call' && session.fileClient
|
|
91
|
+
? await prepareHostFileCall(session.fileClient, frame, controller.signal)
|
|
92
|
+
: frame;
|
|
93
|
+
controller.signal.throwIfAborted();
|
|
94
|
+
let result;
|
|
95
|
+
try {
|
|
96
|
+
result = await session.request(
|
|
97
|
+
{ method: requestFrame.method, params: requestFrame.params ?? {} },
|
|
98
|
+
{ signal: controller.signal },
|
|
99
|
+
);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (isMcpSessionLoss(error)) await evictSession(selector, session);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
controller.signal.throwIfAborted();
|
|
105
|
+
let response = { jsonrpc: '2.0', id: frame.id, result };
|
|
106
|
+
if (frame.method === 'tools/call' && session.fileClient) {
|
|
107
|
+
response = await completeHostFileCall(session.fileClient, frame, response, controller.signal);
|
|
108
|
+
}
|
|
109
|
+
controller.signal.throwIfAborted();
|
|
110
|
+
await send(response);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
await reply(frame.id, error, true);
|
|
113
|
+
} finally {
|
|
114
|
+
if (inFlight.get(frame.id) === controller) inFlight.delete(frame.id);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const close = async () => {
|
|
118
|
+
for (const controller of inFlight.values()) controller.abort('stdio closed');
|
|
119
|
+
await Promise.allSettled([...sessions.values()].map(async (session) => (await session).close()));
|
|
120
|
+
sessions.clear();
|
|
121
|
+
};
|
|
122
|
+
return { handle, close };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function runNetworkProxy({
|
|
126
|
+
env = process.env,
|
|
127
|
+
input = process.stdin,
|
|
128
|
+
stdio = new StdioServerTransport(),
|
|
129
|
+
profile: profileValue,
|
|
130
|
+
hostRecordRoot: hostRecordRootValue,
|
|
131
|
+
sessionFactory: sessionFactoryValue,
|
|
132
|
+
selectorFor,
|
|
133
|
+
installSignalHandlers = true,
|
|
134
|
+
} = {}) {
|
|
135
|
+
const profile = profileValue ?? hostProfileFromEnv(env);
|
|
136
|
+
if (!profile && !sessionFactoryValue) throw new Error('A selected host network profile is required.');
|
|
137
|
+
const configPath = env.OURS_MCP_CONFIG || join(env.HOME || homedir(), '.ours-mcp', 'config.json');
|
|
138
|
+
const hostRecordRoot = hostRecordRootValue ?? dirname(configPath);
|
|
139
|
+
const sessionFactory = sessionFactoryValue ?? createHostSessionFactory({
|
|
140
|
+
profile,
|
|
141
|
+
hostRecordRoot,
|
|
142
|
+
send: (frame) => stdio.send(frame),
|
|
143
|
+
});
|
|
144
|
+
const bridge = createNetworkBridge({ sessionFactory, send: (frame) => stdio.send(frame), selectorFor });
|
|
145
|
+
const tasks = new Set();
|
|
146
|
+
const accept = (raw) => {
|
|
147
|
+
const values = Array.isArray(raw) ? raw : [raw];
|
|
148
|
+
const task = Promise.all(values.map((frame) => bridge.handle(frame)))
|
|
149
|
+
.catch((error) => process.stderr.write(`ours: network proxy failed: ${error.message}\n`))
|
|
150
|
+
.finally(() => tasks.delete(task));
|
|
151
|
+
tasks.add(task);
|
|
152
|
+
};
|
|
153
|
+
stdio.onmessage = accept;
|
|
154
|
+
stdio.onerror = (error) => process.stderr.write(`ours: network stdio failed: ${error.message}\n`);
|
|
155
|
+
await stdio.start();
|
|
156
|
+
|
|
157
|
+
await new Promise((resolve) => {
|
|
158
|
+
let stopped = false;
|
|
159
|
+
const finish = () => {
|
|
160
|
+
if (stopped) return;
|
|
161
|
+
stopped = true;
|
|
162
|
+
input.off('end', finish);
|
|
163
|
+
input.off('close', finish);
|
|
164
|
+
if (installSignalHandlers) {
|
|
165
|
+
process.off('SIGINT', finish);
|
|
166
|
+
process.off('SIGTERM', finish);
|
|
167
|
+
}
|
|
168
|
+
resolve();
|
|
169
|
+
};
|
|
170
|
+
input.once('end', finish);
|
|
171
|
+
input.once('close', finish);
|
|
172
|
+
if (installSignalHandlers) {
|
|
173
|
+
process.once('SIGINT', finish);
|
|
174
|
+
process.once('SIGTERM', finish);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
await bridge.close();
|
|
178
|
+
await Promise.allSettled([...tasks]);
|
|
179
|
+
await stdio.close();
|
|
180
|
+
}
|
|
181
|
+
export { hostProfileFromEnv };
|
|
182
|
+
|
|
183
|
+
export function networkFetchForOwner(profile, ownerInstanceId, { fetchImpl = globalThis.fetch } = {}) {
|
|
184
|
+
return async (input, init = {}) => {
|
|
185
|
+
const token = readFileSync(profile.credentialPath, 'utf8').trim();
|
|
186
|
+
if (!token) throw new Error(`Credential file ${profile.credentialPath} is empty.`);
|
|
187
|
+
const headers = new Headers(init.headers);
|
|
188
|
+
headers.set('x-ours-api-token', token);
|
|
189
|
+
headers.set('x-ours-lease-token', ownerInstanceId);
|
|
190
|
+
headers.set('x-ours-session-mode', 'external');
|
|
191
|
+
try {
|
|
192
|
+
return await fetchImpl(input, { ...init, headers, redirect: 'error' });
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new NetworkTransportError(error);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function createMcpNetworkSession({
|
|
200
|
+
profile,
|
|
201
|
+
ownerInstanceId,
|
|
202
|
+
fileClient,
|
|
203
|
+
onNotification,
|
|
204
|
+
onClose,
|
|
205
|
+
ClientClass = Client,
|
|
206
|
+
TransportClass = StreamableHTTPClientTransport,
|
|
207
|
+
resultSchema = ResultSchema,
|
|
208
|
+
clientInfo = { name: 'ours-codex', version: '2.0.0' },
|
|
209
|
+
}) {
|
|
210
|
+
const client = new ClientClass(clientInfo);
|
|
211
|
+
client.fallbackNotificationHandler = async (notification) => onNotification?.(notification);
|
|
212
|
+
let invalidated = false;
|
|
213
|
+
let closing;
|
|
214
|
+
const evict = () => {
|
|
215
|
+
if (invalidated) return;
|
|
216
|
+
invalidated = true;
|
|
217
|
+
onClose?.();
|
|
218
|
+
};
|
|
219
|
+
const invalidate = async () => {
|
|
220
|
+
evict();
|
|
221
|
+
closing ??= Promise.resolve(client.close()).catch(() => {});
|
|
222
|
+
await closing;
|
|
223
|
+
};
|
|
224
|
+
client.onclose = evict;
|
|
225
|
+
client.onerror = (error) => { if (isMcpSessionLoss(error)) void invalidate(); };
|
|
226
|
+
const ownerHeaders = {
|
|
227
|
+
'x-ours-lease-token': ownerInstanceId,
|
|
228
|
+
'x-ours-session-mode': 'external',
|
|
229
|
+
};
|
|
230
|
+
const endpoint = new URL('/mcp', profile.endpoint);
|
|
231
|
+
const transport = new TransportClass(endpoint, {
|
|
232
|
+
fetch: networkFetchForOwner(profile, ownerInstanceId),
|
|
233
|
+
requestInit: { headers: ownerHeaders, redirect: 'error' },
|
|
234
|
+
reconnectionOptions: {
|
|
235
|
+
initialReconnectionDelay: 1000,
|
|
236
|
+
maxReconnectionDelay: 30_000,
|
|
237
|
+
reconnectionDelayGrowFactor: 1.5,
|
|
238
|
+
maxRetries: 5,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
await client.connect(transport);
|
|
242
|
+
const instructions = client.getInstructions?.();
|
|
243
|
+
const initializeResult = {
|
|
244
|
+
protocolVersion: '2025-03-26',
|
|
245
|
+
capabilities: client.getServerCapabilities?.() ?? {},
|
|
246
|
+
serverInfo: client.getServerVersion?.() ?? { name: 'ours', version: 'unknown' },
|
|
247
|
+
...(instructions ? { instructions } : {}),
|
|
248
|
+
};
|
|
249
|
+
return {
|
|
250
|
+
client,
|
|
251
|
+
fileClient,
|
|
252
|
+
initializeResult,
|
|
253
|
+
request: async (request, options) => {
|
|
254
|
+
try {
|
|
255
|
+
return await client.request(request, resultSchema, options);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (isMcpSessionLoss(error)) await invalidate();
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
notification: (notification) => client.notification(notification),
|
|
262
|
+
close: invalidate,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function nativeOwnerForAtRoot(profile, nativeSessionId, hostRecordRoot) {
|
|
267
|
+
const path = nativeSessionRecordPath(hostRecordRoot, profile.expectedInstanceId, nativeSessionId);
|
|
268
|
+
const record = JSON.parse(readFileSync(path, 'utf8'));
|
|
269
|
+
if (record?.state !== 'active' || typeof record.ownerInstanceId !== 'string') {
|
|
270
|
+
throw new Error(`Native session record at ${path} is not active.`);
|
|
271
|
+
}
|
|
272
|
+
return record.ownerInstanceId;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function createHostSessionFactory({
|
|
276
|
+
profile,
|
|
277
|
+
hostRecordRoot,
|
|
278
|
+
send,
|
|
279
|
+
nativeClientForImpl = nativeClientForAtRoot,
|
|
280
|
+
ownerForImpl = nativeOwnerForAtRoot,
|
|
281
|
+
createSessionImpl = createMcpNetworkSession,
|
|
282
|
+
}) {
|
|
283
|
+
return async (selector, { onClose } = {}) => {
|
|
284
|
+
const discovery = selector === '__discovery__';
|
|
285
|
+
const fileClient = discovery
|
|
286
|
+
? undefined
|
|
287
|
+
: await nativeClientForImpl(profile, selector, hostRecordRoot);
|
|
288
|
+
const ownerInstanceId = discovery
|
|
289
|
+
? randomUUID()
|
|
290
|
+
: await ownerForImpl(profile, selector, hostRecordRoot);
|
|
291
|
+
return createSessionImpl({
|
|
292
|
+
profile,
|
|
293
|
+
ownerInstanceId,
|
|
294
|
+
fileClient,
|
|
295
|
+
onNotification: send,
|
|
296
|
+
onClose,
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function endNetworkNativeSession({
|
|
302
|
+
profile,
|
|
303
|
+
nativeSessionId,
|
|
304
|
+
hostRecordRoot,
|
|
305
|
+
endImpl = endNativeSessionAtRoot,
|
|
306
|
+
}) {
|
|
307
|
+
await endImpl(profile, nativeSessionId, hostRecordRoot);
|
|
308
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { hostProfileFromEnv, nativeClientForAtRoot } from '../../core/src/host-client/index.ts';
|
|
5
|
+
|
|
6
|
+
export async function runNetworkWatch({
|
|
7
|
+
identity,
|
|
8
|
+
nativeSessionId,
|
|
9
|
+
env = process.env,
|
|
10
|
+
profile: profileValue,
|
|
11
|
+
hostRecordRoot: hostRecordRootValue,
|
|
12
|
+
clientFor = nativeClientForAtRoot,
|
|
13
|
+
write = (value) => process.stdout.write(value),
|
|
14
|
+
installSignalHandlers = true,
|
|
15
|
+
} = {}) {
|
|
16
|
+
if (typeof identity !== 'string' || !identity.trim()) throw new Error('identity is required');
|
|
17
|
+
if (typeof nativeSessionId !== 'string' || !nativeSessionId.trim()) throw new Error('native session id is required');
|
|
18
|
+
const profile = profileValue ?? hostProfileFromEnv(env);
|
|
19
|
+
if (!profile) throw new Error('A selected host network profile is required.');
|
|
20
|
+
const appPath = env.OURS_MCP_CONFIG || join(env.HOME || homedir(), '.ours-mcp', 'config.json');
|
|
21
|
+
const hostRecordRoot = hostRecordRootValue ?? dirname(appPath);
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const stop = () => controller.abort();
|
|
24
|
+
if (installSignalHandlers) {
|
|
25
|
+
process.once('SIGINT', stop);
|
|
26
|
+
process.once('SIGTERM', stop);
|
|
27
|
+
}
|
|
28
|
+
let client;
|
|
29
|
+
try {
|
|
30
|
+
client = await clientFor(profile, nativeSessionId, hostRecordRoot);
|
|
31
|
+
for await (const event of client.watchNotifications(identity, { kinds: ['inbound'], signal: controller.signal })) {
|
|
32
|
+
const summary = event?.event === 'file_received'
|
|
33
|
+
? `[${identity}] new file ${event.filename ?? '?'} from ${event.from ?? '?'}`
|
|
34
|
+
: `[${identity}] new message from ${event?.from ?? '?'}`;
|
|
35
|
+
write(`${summary}\n`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
} finally {
|
|
39
|
+
if (installSignalHandlers) {
|
|
40
|
+
process.off('SIGINT', stop);
|
|
41
|
+
process.off('SIGTERM', stop);
|
|
42
|
+
}
|
|
43
|
+
await client?.close();
|
|
44
|
+
}
|
|
45
|
+
}
|