@indykish/oracle 0.9.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.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/assets-oracle-icon.png +0 -0
  4. package/dist/bin/oracle-cli.js +1252 -0
  5. package/dist/bin/oracle-mcp.js +6 -0
  6. package/dist/scripts/agent-send.js +147 -0
  7. package/dist/scripts/browser-tools.js +536 -0
  8. package/dist/scripts/check.js +21 -0
  9. package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
  10. package/dist/scripts/docs-list.js +110 -0
  11. package/dist/scripts/git-policy.js +125 -0
  12. package/dist/scripts/run-cli.js +14 -0
  13. package/dist/scripts/runner.js +1378 -0
  14. package/dist/scripts/test-browser.js +103 -0
  15. package/dist/scripts/test-remote-chrome.js +68 -0
  16. package/dist/src/bridge/connection.js +103 -0
  17. package/dist/src/bridge/userConfigFile.js +28 -0
  18. package/dist/src/browser/actions/assistantResponse.js +1067 -0
  19. package/dist/src/browser/actions/attachmentDataTransfer.js +138 -0
  20. package/dist/src/browser/actions/attachments.js +1910 -0
  21. package/dist/src/browser/actions/domEvents.js +19 -0
  22. package/dist/src/browser/actions/modelSelection.js +485 -0
  23. package/dist/src/browser/actions/navigation.js +445 -0
  24. package/dist/src/browser/actions/promptComposer.js +485 -0
  25. package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
  26. package/dist/src/browser/actions/thinkingTime.js +206 -0
  27. package/dist/src/browser/chromeLifecycle.js +344 -0
  28. package/dist/src/browser/config.js +103 -0
  29. package/dist/src/browser/constants.js +71 -0
  30. package/dist/src/browser/cookies.js +191 -0
  31. package/dist/src/browser/detect.js +164 -0
  32. package/dist/src/browser/domDebug.js +36 -0
  33. package/dist/src/browser/index.js +1741 -0
  34. package/dist/src/browser/modelStrategy.js +13 -0
  35. package/dist/src/browser/pageActions.js +5 -0
  36. package/dist/src/browser/policies.js +43 -0
  37. package/dist/src/browser/profileState.js +280 -0
  38. package/dist/src/browser/prompt.js +152 -0
  39. package/dist/src/browser/promptSummary.js +20 -0
  40. package/dist/src/browser/reattach.js +186 -0
  41. package/dist/src/browser/reattachHelpers.js +382 -0
  42. package/dist/src/browser/sessionRunner.js +119 -0
  43. package/dist/src/browser/types.js +1 -0
  44. package/dist/src/browser/utils.js +122 -0
  45. package/dist/src/browserMode.js +1 -0
  46. package/dist/src/cli/bridge/claudeConfig.js +54 -0
  47. package/dist/src/cli/bridge/client.js +73 -0
  48. package/dist/src/cli/bridge/codexConfig.js +43 -0
  49. package/dist/src/cli/bridge/doctor.js +107 -0
  50. package/dist/src/cli/bridge/host.js +259 -0
  51. package/dist/src/cli/browserConfig.js +278 -0
  52. package/dist/src/cli/browserDefaults.js +81 -0
  53. package/dist/src/cli/bundleWarnings.js +9 -0
  54. package/dist/src/cli/clipboard.js +10 -0
  55. package/dist/src/cli/detach.js +11 -0
  56. package/dist/src/cli/dryRun.js +105 -0
  57. package/dist/src/cli/duplicatePromptGuard.js +14 -0
  58. package/dist/src/cli/engine.js +41 -0
  59. package/dist/src/cli/errorUtils.js +9 -0
  60. package/dist/src/cli/format.js +13 -0
  61. package/dist/src/cli/help.js +77 -0
  62. package/dist/src/cli/hiddenAliases.js +22 -0
  63. package/dist/src/cli/markdownBundle.js +17 -0
  64. package/dist/src/cli/markdownRenderer.js +97 -0
  65. package/dist/src/cli/notifier.js +306 -0
  66. package/dist/src/cli/options.js +281 -0
  67. package/dist/src/cli/oscUtils.js +2 -0
  68. package/dist/src/cli/promptRequirement.js +17 -0
  69. package/dist/src/cli/renderFlags.js +9 -0
  70. package/dist/src/cli/renderOutput.js +26 -0
  71. package/dist/src/cli/rootAlias.js +30 -0
  72. package/dist/src/cli/runOptions.js +78 -0
  73. package/dist/src/cli/sessionCommand.js +111 -0
  74. package/dist/src/cli/sessionDisplay.js +567 -0
  75. package/dist/src/cli/sessionRunner.js +602 -0
  76. package/dist/src/cli/sessionTable.js +92 -0
  77. package/dist/src/cli/tagline.js +258 -0
  78. package/dist/src/cli/tui/index.js +486 -0
  79. package/dist/src/cli/writeOutputPath.js +21 -0
  80. package/dist/src/config.js +26 -0
  81. package/dist/src/gemini-web/client.js +328 -0
  82. package/dist/src/gemini-web/executor.js +285 -0
  83. package/dist/src/gemini-web/index.js +1 -0
  84. package/dist/src/gemini-web/types.js +1 -0
  85. package/dist/src/heartbeat.js +43 -0
  86. package/dist/src/mcp/server.js +40 -0
  87. package/dist/src/mcp/tools/consult.js +290 -0
  88. package/dist/src/mcp/tools/sessionResources.js +75 -0
  89. package/dist/src/mcp/tools/sessions.js +105 -0
  90. package/dist/src/mcp/types.js +22 -0
  91. package/dist/src/mcp/utils.js +37 -0
  92. package/dist/src/oracle/background.js +141 -0
  93. package/dist/src/oracle/claude.js +101 -0
  94. package/dist/src/oracle/client.js +197 -0
  95. package/dist/src/oracle/config.js +227 -0
  96. package/dist/src/oracle/errors.js +132 -0
  97. package/dist/src/oracle/files.js +378 -0
  98. package/dist/src/oracle/finishLine.js +32 -0
  99. package/dist/src/oracle/format.js +30 -0
  100. package/dist/src/oracle/fsAdapter.js +10 -0
  101. package/dist/src/oracle/gemini.js +195 -0
  102. package/dist/src/oracle/logging.js +36 -0
  103. package/dist/src/oracle/markdown.js +46 -0
  104. package/dist/src/oracle/modelResolver.js +183 -0
  105. package/dist/src/oracle/multiModelRunner.js +153 -0
  106. package/dist/src/oracle/oscProgress.js +24 -0
  107. package/dist/src/oracle/promptAssembly.js +13 -0
  108. package/dist/src/oracle/request.js +50 -0
  109. package/dist/src/oracle/run.js +596 -0
  110. package/dist/src/oracle/runUtils.js +31 -0
  111. package/dist/src/oracle/tokenEstimate.js +37 -0
  112. package/dist/src/oracle/tokenStats.js +39 -0
  113. package/dist/src/oracle/tokenStringifier.js +24 -0
  114. package/dist/src/oracle/types.js +1 -0
  115. package/dist/src/oracle.js +12 -0
  116. package/dist/src/oracleHome.js +13 -0
  117. package/dist/src/remote/client.js +129 -0
  118. package/dist/src/remote/health.js +113 -0
  119. package/dist/src/remote/remoteServiceConfig.js +31 -0
  120. package/dist/src/remote/server.js +533 -0
  121. package/dist/src/remote/types.js +1 -0
  122. package/dist/src/sessionManager.js +637 -0
  123. package/dist/src/sessionStore.js +56 -0
  124. package/dist/src/version.js +39 -0
  125. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  126. package/dist/vendor/oracle-notifier/README.md +24 -0
  127. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  128. package/package.json +115 -0
  129. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  130. package/vendor/oracle-notifier/README.md +24 -0
  131. package/vendor/oracle-notifier/build-notifier.sh +93 -0
@@ -0,0 +1,122 @@
1
+ export function parseDuration(input, fallback) {
2
+ if (!input) {
3
+ return fallback;
4
+ }
5
+ const trimmed = input.trim();
6
+ if (!trimmed) {
7
+ return fallback;
8
+ }
9
+ const lowercase = trimmed.toLowerCase();
10
+ if (/^[0-9]+$/.test(lowercase)) {
11
+ return Number(lowercase);
12
+ }
13
+ const normalized = lowercase.replace(/\s+/g, '');
14
+ const singleMatch = /^([0-9]+)(ms|s|m|h)$/i.exec(normalized);
15
+ if (singleMatch && singleMatch[0].length === normalized.length) {
16
+ const value = Number(singleMatch[1]);
17
+ return convertUnit(value, singleMatch[2]);
18
+ }
19
+ const multiDuration = /([0-9]+)(ms|h|m|s)/g;
20
+ let total = 0;
21
+ let lastIndex = 0;
22
+ let match = multiDuration.exec(normalized);
23
+ while (match !== null) {
24
+ total += convertUnit(Number(match[1]), match[2]);
25
+ lastIndex = multiDuration.lastIndex;
26
+ match = multiDuration.exec(normalized);
27
+ }
28
+ if (total > 0 && lastIndex === normalized.length) {
29
+ return total;
30
+ }
31
+ return fallback;
32
+ }
33
+ function convertUnit(value, unitRaw) {
34
+ const unit = unitRaw?.toLowerCase();
35
+ switch (unit) {
36
+ case 'ms':
37
+ return value;
38
+ case 's':
39
+ return value * 1000;
40
+ case 'm':
41
+ return value * 60_000;
42
+ case 'h':
43
+ return value * 3_600_000;
44
+ default:
45
+ return value;
46
+ }
47
+ }
48
+ export function delay(ms) {
49
+ return new Promise((resolve) => setTimeout(resolve, ms));
50
+ }
51
+ export function estimateTokenCount(text) {
52
+ if (!text) {
53
+ return 0;
54
+ }
55
+ const words = text.trim().split(/\s+/).filter(Boolean);
56
+ const estimate = Math.max(words.length * 0.75, text.length / 4);
57
+ return Math.max(1, Math.round(estimate));
58
+ }
59
+ export async function withRetries(task, options = {}) {
60
+ const { retries = 2, delayMs = 250, onRetry } = options;
61
+ let attempt = 0;
62
+ while (attempt <= retries) {
63
+ try {
64
+ return await task();
65
+ }
66
+ catch (error) {
67
+ if (attempt === retries) {
68
+ throw error;
69
+ }
70
+ attempt += 1;
71
+ onRetry?.(attempt, error);
72
+ await delay(delayMs * attempt);
73
+ }
74
+ }
75
+ throw new Error('withRetries exhausted without result');
76
+ }
77
+ export function formatBytes(size) {
78
+ if (!Number.isFinite(size) || size < 0) {
79
+ return 'n/a';
80
+ }
81
+ if (size < 1024) {
82
+ return `${size} B`;
83
+ }
84
+ if (size < 1024 * 1024) {
85
+ return `${(size / 1024).toFixed(1)} KB`;
86
+ }
87
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
88
+ }
89
+ /**
90
+ * Normalizes a ChatGPT URL, ensuring it is absolute, uses http/https, and trims whitespace.
91
+ * Falls back to the provided default when input is empty/undefined.
92
+ */
93
+ export function normalizeChatgptUrl(raw, fallback) {
94
+ const candidate = raw?.trim();
95
+ if (!candidate) {
96
+ return fallback;
97
+ }
98
+ const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(candidate);
99
+ const withScheme = hasScheme ? candidate : `https://${candidate}`;
100
+ let parsed;
101
+ try {
102
+ parsed = new URL(withScheme);
103
+ }
104
+ catch {
105
+ throw new Error(`Invalid ChatGPT URL: "${raw}". Provide an absolute http(s) URL.`);
106
+ }
107
+ if (!/^https?:$/i.test(parsed.protocol)) {
108
+ throw new Error(`Invalid ChatGPT URL protocol: "${parsed.protocol}". Use http or https.`);
109
+ }
110
+ // Preserve user-provided path/query; URL#toString will normalize trailing slashes appropriately.
111
+ return parsed.toString();
112
+ }
113
+ export function isTemporaryChatUrl(url) {
114
+ try {
115
+ const parsed = new URL(url);
116
+ const value = (parsed.searchParams.get('temporary-chat') ?? '').trim().toLowerCase();
117
+ return value === 'true' || value === '1' || value === 'yes';
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
@@ -0,0 +1 @@
1
+ export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, isTemporaryChatUrl, } from './browser/index.js';
@@ -0,0 +1,54 @@
1
+ import chalk from 'chalk';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { loadUserConfig } from '../../config.js';
5
+ import { resolveRemoteServiceConfig } from '../../remote/remoteServiceConfig.js';
6
+ export async function runBridgeClaudeConfig(options) {
7
+ const { config: userConfig } = await loadUserConfig();
8
+ const resolved = resolveRemoteServiceConfig({
9
+ cliHost: undefined,
10
+ cliToken: undefined,
11
+ userConfig,
12
+ env: process.env,
13
+ });
14
+ const snippet = formatClaudeMcpConfig({
15
+ oracleHomeDir: process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle-local'),
16
+ browserProfileDir: process.env.ORACLE_BROWSER_PROFILE_DIR ??
17
+ path.join(os.homedir(), '.oracle-local', 'browser-profile'),
18
+ remoteHost: resolved.host,
19
+ remoteToken: resolved.token,
20
+ includeToken: Boolean(options.printToken),
21
+ });
22
+ console.log(snippet);
23
+ if (!options.printToken) {
24
+ console.log('');
25
+ console.log(chalk.dim('Tip: rerun with --print-token to include ORACLE_REMOTE_TOKEN in the snippet.'));
26
+ }
27
+ }
28
+ export function formatClaudeMcpConfig({ oracleHomeDir, browserProfileDir, remoteHost, remoteToken, includeToken, }) {
29
+ const env = {};
30
+ // biome-ignore lint/complexity/useLiteralKeys: env vars are uppercase and include underscores.
31
+ env['ORACLE_ENGINE'] = 'browser';
32
+ // biome-ignore lint/complexity/useLiteralKeys: env vars are uppercase and include underscores.
33
+ env['ORACLE_HOME_DIR'] = oracleHomeDir;
34
+ // biome-ignore lint/complexity/useLiteralKeys: env vars are uppercase and include underscores.
35
+ env['ORACLE_BROWSER_PROFILE_DIR'] = browserProfileDir;
36
+ if (remoteHost) {
37
+ // biome-ignore lint/complexity/useLiteralKeys: env vars are uppercase and include underscores.
38
+ env['ORACLE_REMOTE_HOST'] = remoteHost;
39
+ // biome-ignore lint/complexity/useLiteralKeys: env vars are uppercase and include underscores.
40
+ env['ORACLE_REMOTE_TOKEN'] = includeToken ? remoteToken ?? '<YOUR_TOKEN>' : '<YOUR_TOKEN>';
41
+ }
42
+ // Claude Code supports project-scoped `.mcp.json` config files:
43
+ // https://docs.anthropic.com/en/docs/claude-code/mcp
44
+ return JSON.stringify({
45
+ mcpServers: {
46
+ oracle: {
47
+ type: 'stdio',
48
+ command: 'oracle-mcp',
49
+ args: [],
50
+ env,
51
+ },
52
+ },
53
+ }, null, 2);
54
+ }
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { configPath as defaultConfigPath } from '../../config.js';
5
+ import { parseBridgeConnectionString, readBridgeConnectionArtifact, looksLikePath, } from '../../bridge/connection.js';
6
+ import { readUserConfigFile, writeUserConfigFile } from '../../bridge/userConfigFile.js';
7
+ import { checkRemoteHealth } from '../../remote/health.js';
8
+ export async function runBridgeClient(options) {
9
+ const connectRaw = options.connect?.trim();
10
+ if (!connectRaw) {
11
+ throw new Error('Missing --connect. Provide a connection string or a bridge-connection.json path.');
12
+ }
13
+ const { remoteHost, remoteToken, tunnel } = await resolveConnection(connectRaw);
14
+ if (options.test !== false) {
15
+ const health = await checkRemoteHealth({ host: remoteHost, token: remoteToken, timeoutMs: 5000 });
16
+ if (!health.ok) {
17
+ const suffix = health.statusCode ? ` (HTTP ${health.statusCode})` : '';
18
+ throw new Error(`Remote service health check failed: ${health.error ?? 'unknown error'}${suffix}`);
19
+ }
20
+ console.log(chalk.green(`Remote service OK (${remoteHost})${health.version ? ` — oracle ${health.version}` : ''}`));
21
+ }
22
+ const configFilePath = options.config?.trim() || defaultConfigPath();
23
+ if (options.writeConfig !== false) {
24
+ const { config } = await readUserConfigFile(configFilePath);
25
+ const next = { ...config, browser: { ...(config.browser ?? {}) } };
26
+ next.browser = { ...(next.browser ?? {}) };
27
+ next.browser.remoteHost = remoteHost;
28
+ next.browser.remoteToken = remoteToken;
29
+ if (tunnel) {
30
+ next.browser.remoteViaSshReverseTunnel = {
31
+ ssh: tunnel.ssh,
32
+ remotePort: tunnel.remotePort,
33
+ localPort: tunnel.localPort,
34
+ identity: tunnel.identity,
35
+ extraArgs: tunnel.extraArgs,
36
+ };
37
+ }
38
+ await writeUserConfigFile(configFilePath, next);
39
+ console.log(chalk.green(`Wrote remote config to ${configFilePath}`));
40
+ }
41
+ console.log('');
42
+ console.log('Next:');
43
+ console.log(chalk.dim(`- oracle --engine browser -p "hello" --file README.md`));
44
+ if (options.printEnv) {
45
+ console.log('');
46
+ console.log('# Optional env overrides (paste into your shell):');
47
+ console.log(`export ORACLE_ENGINE=browser`);
48
+ console.log(`export ORACLE_REMOTE_HOST=${shellQuote(remoteHost)}`);
49
+ console.log(`export ORACLE_REMOTE_TOKEN=${shellQuote(remoteToken)}`);
50
+ }
51
+ }
52
+ async function resolveConnection(input) {
53
+ if (input.includes('://')) {
54
+ return { ...parseBridgeConnectionString(input) };
55
+ }
56
+ const resolvedPath = looksLikePath(input) ? path.resolve(process.cwd(), input) : null;
57
+ if (resolvedPath) {
58
+ const stat = await fs.stat(resolvedPath).catch(() => null);
59
+ if (stat?.isFile()) {
60
+ const artifact = await readBridgeConnectionArtifact(resolvedPath);
61
+ return { remoteHost: artifact.remoteHost, remoteToken: artifact.remoteToken, tunnel: artifact.tunnel };
62
+ }
63
+ if (stat) {
64
+ throw new Error(`--connect points to ${resolvedPath}, but it is not a file.`);
65
+ }
66
+ throw new Error(`Connection artifact not found at ${resolvedPath}`);
67
+ }
68
+ return { ...parseBridgeConnectionString(input) };
69
+ }
70
+ function shellQuote(value) {
71
+ // Single-quote for POSIX shells; safe for tokens/host strings.
72
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
73
+ }
@@ -0,0 +1,43 @@
1
+ import chalk from 'chalk';
2
+ import { loadUserConfig } from '../../config.js';
3
+ import { resolveRemoteServiceConfig } from '../../remote/remoteServiceConfig.js';
4
+ export async function runBridgeCodexConfig(options) {
5
+ const { config: userConfig } = await loadUserConfig();
6
+ const resolved = resolveRemoteServiceConfig({
7
+ cliHost: undefined,
8
+ cliToken: undefined,
9
+ userConfig,
10
+ env: process.env,
11
+ });
12
+ const snippet = formatCodexMcpSnippet({
13
+ remoteHost: resolved.host,
14
+ remoteToken: resolved.token,
15
+ includeToken: Boolean(options.printToken),
16
+ });
17
+ console.log(snippet);
18
+ if (!options.printToken) {
19
+ console.log('');
20
+ console.log(chalk.dim('Tip: rerun with --print-token to include ORACLE_REMOTE_TOKEN in the snippet.'));
21
+ }
22
+ }
23
+ export function formatCodexMcpSnippet({ remoteHost, remoteToken, includeToken, }) {
24
+ const hostValue = remoteHost ?? '127.0.0.1:9473';
25
+ const tokenValue = includeToken ? remoteToken ?? '<YOUR_TOKEN>' : '<YOUR_TOKEN>';
26
+ return [
27
+ '# ~/.codex/config.toml',
28
+ '',
29
+ '[mcp.servers.oracle]',
30
+ 'command = "oracle-mcp"',
31
+ 'args = []',
32
+ `env = { ORACLE_ENGINE = "browser", ORACLE_REMOTE_HOST = "${escapeTomlString(hostValue)}", ORACLE_REMOTE_TOKEN = "${escapeTomlString(tokenValue)}" }`,
33
+ '',
34
+ '# If you prefer npx:',
35
+ '# [mcp.servers.oracle]',
36
+ '# command = "npx"',
37
+ '# args = ["-y", "@steipete/oracle", "oracle-mcp"]',
38
+ `# env = { ORACLE_ENGINE = "browser", ORACLE_REMOTE_HOST = "${escapeTomlString(hostValue)}", ORACLE_REMOTE_TOKEN = "${escapeTomlString(tokenValue)}" }`,
39
+ ].join('\n');
40
+ }
41
+ function escapeTomlString(value) {
42
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
43
+ }
@@ -0,0 +1,107 @@
1
+ import os from 'node:os';
2
+ import chalk from 'chalk';
3
+ import { getCliVersion } from '../../version.js';
4
+ import { loadUserConfig } from '../../config.js';
5
+ import { resolveRemoteServiceConfig } from '../../remote/remoteServiceConfig.js';
6
+ import { checkTcpConnection, checkRemoteHealth } from '../../remote/health.js';
7
+ import { detectChromeBinary, detectChromeCookieDb } from '../../browser/detect.js';
8
+ import { formatCodexMcpSnippet } from './codexConfig.js';
9
+ export async function runBridgeDoctor(_options) {
10
+ const { config: userConfig, path: configPath, loaded } = await loadUserConfig();
11
+ const version = getCliVersion();
12
+ const resolvedRemote = resolveRemoteServiceConfig({
13
+ cliHost: undefined,
14
+ cliToken: undefined,
15
+ userConfig,
16
+ env: process.env,
17
+ });
18
+ const lines = [];
19
+ const fail = [];
20
+ const warn = [];
21
+ lines.push(chalk.bold('Bridge doctor'));
22
+ lines.push(chalk.dim(`OS: ${process.platform} ${os.release()} (${process.arch})`));
23
+ lines.push(chalk.dim(`Node: ${process.version}`));
24
+ lines.push(chalk.dim(`Oracle: ${version}`));
25
+ lines.push(chalk.dim(`Config: ${loaded ? configPath : '(missing)'}`));
26
+ if (userConfig.engine) {
27
+ lines.push(chalk.dim(`Default engine: ${userConfig.engine}`));
28
+ }
29
+ if (userConfig.model) {
30
+ lines.push(chalk.dim(`Default model: ${userConfig.model}`));
31
+ }
32
+ lines.push('');
33
+ lines.push(chalk.bold('Browser mode'));
34
+ if (resolvedRemote.host) {
35
+ lines.push(`Remote service: ${chalk.green('configured')}`);
36
+ lines.push(chalk.dim(`remoteHost: ${resolvedRemote.host} (${resolvedRemote.sources.host})`));
37
+ lines.push(chalk.dim(`remoteToken: ${resolvedRemote.token ? 'set' : 'missing'} (${resolvedRemote.sources.token})`));
38
+ const tcp = await checkTcpConnection(resolvedRemote.host, 2000);
39
+ if (tcp.ok) {
40
+ lines.push(chalk.dim(`TCP connect: ${chalk.green('ok')}`));
41
+ }
42
+ else {
43
+ fail.push(`Cannot reach ${resolvedRemote.host} (${tcp.error ?? 'unknown error'}).`);
44
+ lines.push(chalk.dim(`TCP connect: ${chalk.red(`failed (${tcp.error ?? 'unknown error'})`)}`));
45
+ }
46
+ if (!resolvedRemote.token) {
47
+ fail.push('Remote token is missing. Run `oracle bridge client --connect <...> --write-config` or set ORACLE_REMOTE_TOKEN.');
48
+ }
49
+ else if (tcp.ok) {
50
+ const health = await checkRemoteHealth({ host: resolvedRemote.host, token: resolvedRemote.token, timeoutMs: 5000 });
51
+ if (health.ok) {
52
+ const meta = health.version ? `oracle ${health.version}` : 'ok';
53
+ lines.push(chalk.dim(`Auth (/health): ${chalk.green(meta)}`));
54
+ }
55
+ else {
56
+ const detail = health.error ?? 'unknown error';
57
+ fail.push(`Remote auth failed: ${detail}`);
58
+ const suffix = health.statusCode ? `HTTP ${health.statusCode}` : 'network';
59
+ lines.push(chalk.dim(`Auth (/health): ${chalk.red(`${suffix} (${detail})`)}`));
60
+ }
61
+ }
62
+ }
63
+ else {
64
+ lines.push(`Remote service: ${chalk.yellow('not configured')}`);
65
+ const chrome = await detectChromeBinary();
66
+ if (chrome.path) {
67
+ lines.push(chalk.dim(`Chrome: ${chalk.green(chrome.path)}`));
68
+ }
69
+ else {
70
+ fail.push('No Chrome installation detected. Install Chrome/Chromium or set --browser-chrome-path.');
71
+ lines.push(chalk.dim(`Chrome: ${chalk.red('not found')}`));
72
+ }
73
+ if (process.platform === 'win32') {
74
+ warn.push('Cookie sync is disabled on Windows; use --browser-manual-login or run browser automation on another host.');
75
+ lines.push(chalk.dim('Cookies: (cookie sync disabled on Windows)'));
76
+ }
77
+ else {
78
+ const cookieDb = await detectChromeCookieDb({ profile: 'Default' });
79
+ if (cookieDb) {
80
+ lines.push(chalk.dim(`Cookies DB: ${chalk.green(cookieDb)}`));
81
+ }
82
+ else {
83
+ warn.push('Chrome cookies DB not detected. You may need --browser-cookie-path or --browser-manual-login.');
84
+ lines.push(chalk.dim(`Cookies DB: ${chalk.yellow('not found')}`));
85
+ }
86
+ }
87
+ }
88
+ lines.push('');
89
+ lines.push(chalk.bold('Codex MCP'));
90
+ lines.push(formatCodexMcpSnippet({ remoteHost: resolvedRemote.host, remoteToken: resolvedRemote.token, includeToken: false }));
91
+ if (warn.length) {
92
+ lines.push('');
93
+ lines.push(chalk.yellowBright('Warnings:'));
94
+ for (const message of warn) {
95
+ lines.push(chalk.yellow(`- ${message}`));
96
+ }
97
+ }
98
+ if (fail.length) {
99
+ lines.push('');
100
+ lines.push(chalk.redBright('Problems:'));
101
+ for (const message of fail) {
102
+ lines.push(chalk.red(`- ${message}`));
103
+ }
104
+ }
105
+ console.log(lines.join('\n'));
106
+ process.exitCode = fail.length ? 1 : 0;
107
+ }
@@ -0,0 +1,259 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+ import chalk from 'chalk';
6
+ import { getOracleHomeDir } from '../../oracleHome.js';
7
+ import { parseHostPort, normalizeHostPort, formatBridgeConnectionString } from '../../bridge/connection.js';
8
+ import { serveRemote } from '../../remote/server.js';
9
+ export async function runBridgeHost(options) {
10
+ const bindRaw = options.bind?.trim() || '127.0.0.1:9473';
11
+ const { hostname: bindHost, port: bindPort } = parseHostPort(bindRaw);
12
+ const tokenRaw = options.token?.trim() || 'auto';
13
+ const token = tokenRaw === 'auto' ? randomBytes(16).toString('hex') : tokenRaw;
14
+ if (!token.trim()) {
15
+ throw new Error('Token is required (use --token auto to generate one).');
16
+ }
17
+ const writeConnectionPath = options.writeConnection?.trim() || path.join(getOracleHomeDir(), 'bridge-connection.json');
18
+ const sshTarget = options.ssh?.trim();
19
+ const sshRemotePort = typeof options.sshRemotePort === 'number' ? options.sshRemotePort : bindPort;
20
+ if (sshRemotePort <= 0 || sshRemotePort > 65_535) {
21
+ throw new Error(`Invalid --ssh-remote-port: ${sshRemotePort}. Expected 1-65535.`);
22
+ }
23
+ const connectionHostForClient = sshTarget ? normalizeHostPort('127.0.0.1', sshRemotePort) : normalizeHostPort(bindHost === '0.0.0.0' || bindHost === '::' ? '127.0.0.1' : bindHost, bindPort);
24
+ const artifact = await upsertConnectionArtifact(writeConnectionPath, {
25
+ remoteHost: connectionHostForClient,
26
+ remoteToken: token,
27
+ tunnel: sshTarget
28
+ ? {
29
+ ssh: sshTarget,
30
+ remotePort: sshRemotePort,
31
+ localPort: bindPort,
32
+ identity: options.sshIdentity?.trim() || undefined,
33
+ extraArgs: options.sshExtraArgs?.trim() || undefined,
34
+ }
35
+ : undefined,
36
+ });
37
+ if (options.printToken) {
38
+ console.log(token);
39
+ }
40
+ if (options.print) {
41
+ console.log(formatBridgeConnectionString({ remoteHost: artifact.remoteHost, remoteToken: token }, { includeToken: true }));
42
+ }
43
+ if (options.background) {
44
+ await spawnBridgeHostInBackground({
45
+ bind: bindRaw,
46
+ token,
47
+ writeConnectionPath,
48
+ sshTarget,
49
+ sshRemotePort,
50
+ sshIdentity: options.sshIdentity?.trim(),
51
+ sshExtraArgs: options.sshExtraArgs?.trim(),
52
+ });
53
+ return;
54
+ }
55
+ console.log(chalk.cyanBright('Bridge host starting...'));
56
+ console.log(chalk.dim(`- Local bind: ${normalizeHostPort(bindHost, bindPort)}`));
57
+ console.log(chalk.dim(`- Connection artifact: ${writeConnectionPath}`));
58
+ console.log(chalk.dim(`- Client remoteHost: ${artifact.remoteHost}`));
59
+ console.log(chalk.dim('Token stored in connection artifact (not printed). Use --print or --print-token if needed.'));
60
+ let tunnel = null;
61
+ if (sshTarget) {
62
+ tunnel = startReverseTunnel({
63
+ sshTarget,
64
+ remotePort: sshRemotePort,
65
+ localPort: bindPort,
66
+ identity: options.sshIdentity?.trim() || undefined,
67
+ extraArgs: options.sshExtraArgs?.trim() || undefined,
68
+ log: (msg) => console.log(chalk.dim(msg)),
69
+ });
70
+ console.log(chalk.dim(`Reverse SSH tunnel active (remote 127.0.0.1:${sshRemotePort} -> local 127.0.0.1:${bindPort})`));
71
+ }
72
+ const filteredServeLogger = (message) => {
73
+ if (message.includes('Access token:')) {
74
+ return;
75
+ }
76
+ console.log(message);
77
+ };
78
+ try {
79
+ await serveRemote({
80
+ host: bindHost,
81
+ port: bindPort,
82
+ token,
83
+ logger: filteredServeLogger,
84
+ });
85
+ }
86
+ finally {
87
+ tunnel?.stop();
88
+ }
89
+ }
90
+ async function upsertConnectionArtifact(filePath, input) {
91
+ const dir = path.dirname(filePath);
92
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
93
+ const now = new Date().toISOString();
94
+ const existing = await fs.readFile(filePath, 'utf8').catch(() => null);
95
+ let createdAt = now;
96
+ if (existing) {
97
+ try {
98
+ const parsed = JSON.parse(existing);
99
+ if (typeof parsed.createdAt === 'string' && parsed.createdAt.trim().length > 0) {
100
+ createdAt = parsed.createdAt;
101
+ }
102
+ }
103
+ catch {
104
+ // ignore invalid previous content
105
+ }
106
+ }
107
+ const artifact = {
108
+ remoteHost: input.remoteHost,
109
+ remoteToken: input.remoteToken,
110
+ createdAt,
111
+ updatedAt: now,
112
+ tunnel: input.tunnel,
113
+ };
114
+ const contents = `${JSON.stringify(artifact, null, 2)}\n`;
115
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
116
+ await fs.writeFile(tempPath, contents, { encoding: 'utf8', mode: 0o600 });
117
+ await fs.rename(tempPath, filePath);
118
+ if (process.platform !== 'win32') {
119
+ await fs.chmod(filePath, 0o600).catch(() => undefined);
120
+ }
121
+ return artifact;
122
+ }
123
+ function startReverseTunnel({ sshTarget, remotePort, localPort, identity, extraArgs, log, }) {
124
+ let stopped = false;
125
+ let child = null;
126
+ let attempt = 0;
127
+ let timer = null;
128
+ const spawnOnce = () => {
129
+ if (stopped)
130
+ return;
131
+ const args = [
132
+ '-N',
133
+ '-R',
134
+ `${remotePort}:127.0.0.1:${localPort}`,
135
+ '-o',
136
+ 'ExitOnForwardFailure=yes',
137
+ '-o',
138
+ 'ServerAliveInterval=30',
139
+ '-o',
140
+ 'ServerAliveCountMax=3',
141
+ ];
142
+ if (identity) {
143
+ args.push('-i', identity);
144
+ }
145
+ if (extraArgs) {
146
+ args.push(...splitArgs(extraArgs));
147
+ }
148
+ args.push(sshTarget);
149
+ child = spawn('ssh', args, { stdio: 'ignore' });
150
+ const pid = child.pid;
151
+ log(`[bridge host] ssh tunnel started${pid ? ` (pid ${pid})` : ''}: ${sshTarget}`);
152
+ child.once('exit', (code, signal) => {
153
+ child = null;
154
+ if (stopped)
155
+ return;
156
+ const label = signal ? `signal ${signal}` : `code ${code ?? 0}`;
157
+ const delayMs = Math.min(30_000, 1_000 * 2 ** attempt);
158
+ attempt += 1;
159
+ log(`[bridge host] ssh tunnel exited (${label}); restarting in ${delayMs}ms`);
160
+ timer = setTimeout(spawnOnce, delayMs);
161
+ timer.unref?.();
162
+ });
163
+ };
164
+ spawnOnce();
165
+ return {
166
+ stop: () => {
167
+ stopped = true;
168
+ if (timer) {
169
+ clearTimeout(timer);
170
+ timer = null;
171
+ }
172
+ if (child) {
173
+ child.removeAllListeners();
174
+ child.kill();
175
+ child = null;
176
+ }
177
+ },
178
+ };
179
+ }
180
+ function splitArgs(input) {
181
+ const args = [];
182
+ let current = '';
183
+ let quote = null;
184
+ const push = () => {
185
+ const trimmed = current.trim();
186
+ if (trimmed.length)
187
+ args.push(trimmed);
188
+ current = '';
189
+ };
190
+ for (let i = 0; i < input.length; i += 1) {
191
+ const ch = input[i] ?? '';
192
+ if (quote) {
193
+ if (ch === quote) {
194
+ quote = null;
195
+ }
196
+ else {
197
+ current += ch;
198
+ }
199
+ continue;
200
+ }
201
+ if (ch === '"' || ch === "'") {
202
+ quote = ch;
203
+ continue;
204
+ }
205
+ if (/\s/.test(ch)) {
206
+ push();
207
+ continue;
208
+ }
209
+ current += ch;
210
+ }
211
+ push();
212
+ return args;
213
+ }
214
+ async function spawnBridgeHostInBackground({ bind, token, writeConnectionPath, sshTarget, sshRemotePort, sshIdentity, sshExtraArgs, }) {
215
+ const oracleHome = getOracleHomeDir();
216
+ await fs.mkdir(oracleHome, { recursive: true, mode: 0o700 });
217
+ const logPath = path.join(oracleHome, 'bridge-host.log');
218
+ const pidPath = path.join(oracleHome, 'bridge-host.pid');
219
+ const logHandle = await fs.open(logPath, 'a');
220
+ const stdio = ['ignore', logHandle.fd, logHandle.fd];
221
+ const scriptPath = process.argv[1];
222
+ if (!scriptPath) {
223
+ throw new Error('Unable to determine CLI entrypoint for background mode.');
224
+ }
225
+ const args = [
226
+ scriptPath,
227
+ 'bridge',
228
+ 'host',
229
+ '--foreground',
230
+ '--bind',
231
+ bind,
232
+ '--token',
233
+ token,
234
+ '--write-connection',
235
+ writeConnectionPath,
236
+ ];
237
+ if (sshTarget) {
238
+ args.push('--ssh', sshTarget);
239
+ }
240
+ if (typeof sshRemotePort === 'number') {
241
+ args.push('--ssh-remote-port', String(sshRemotePort));
242
+ }
243
+ if (sshIdentity) {
244
+ args.push('--ssh-identity', sshIdentity);
245
+ }
246
+ if (sshExtraArgs) {
247
+ args.push('--ssh-extra-args', sshExtraArgs);
248
+ }
249
+ const child = spawn(process.execPath, args, { detached: true, stdio });
250
+ child.unref();
251
+ await fs.writeFile(pidPath, `${child.pid ?? ''}\n`, { encoding: 'utf8', mode: 0o600 });
252
+ if (process.platform !== 'win32') {
253
+ await fs.chmod(pidPath, 0o600).catch(() => undefined);
254
+ }
255
+ await logHandle.close();
256
+ console.log(chalk.green(`Bridge host running in background (pid ${child.pid ?? '?'})`));
257
+ console.log(chalk.dim(`- Log: ${logPath}`));
258
+ console.log(chalk.dim(`- PID: ${pidPath}`));
259
+ }