@canary-builds/dsh-connect 0.3.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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +99 -0
- package/package.json +68 -0
- package/src/adapter.js +206 -0
- package/src/client.js +94 -0
- package/src/doctor.js +15 -0
- package/src/errors.js +25 -0
- package/src/http.js +76 -0
- package/src/index.js +20 -0
- package/src/login.js +14 -0
- package/src/models.js +54 -0
- package/src/runtime.js +44 -0
- package/src/transport.js +191 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.1 — 2026-09-06
|
|
4
|
+
|
|
5
|
+
First packaged GitHub release.
|
|
6
|
+
|
|
7
|
+
- Provide a prebuilt installable Profile Bundle and SHA-256 checksum.
|
|
8
|
+
- Derive runtime version from package metadata to prevent release drift.
|
|
9
|
+
- Validate packed contents and installed entry points in CI.
|
|
10
|
+
- Add tag-driven GitHub releases, optional npm trusted publishing, and submission documentation.
|
|
11
|
+
|
|
12
|
+
## 0.3.0 — DSH Connect
|
|
13
|
+
|
|
14
|
+
- Rename the package, client module, Settings panel, HTTP route and CLI commands.
|
|
15
|
+
- Establish `Canary-Builds/dhs-connect` as the source repository.
|
|
16
|
+
- Discover Codex on PATH or through explicit configuration.
|
|
17
|
+
- Make deployment-specific proxy exceptions opt-in.
|
|
18
|
+
- Keep the existing `openai-codex` provider route and compatible sign-in location.
|
|
19
|
+
- Preserve streaming, tool bridging, recovery and the automated test suite from the earlier local adapter.
|
|
20
|
+
|
|
21
|
+
This is a development checkpoint, not a published npm release.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Canary Builds
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# DSH Connect
|
|
2
|
+
|
|
3
|
+
Connect DeepSeek Harness to ChatGPT models through the official Codex app-server.
|
|
4
|
+
|
|
5
|
+
DSH Connect is a community Cordis plugin with a model-provider adapter and a web Settings panel. The first connector supports Codex/ChatGPT; it is not tied to a particular model. The provider ID is `openai-codex`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Requires Node.js 22.19+ and an installed official Codex CLI. Tested with DSH 0.1.1-rc.2 and Codex 0.153.4 on Linux. Both upstream interfaces are evolving; other versions and operating systems need validation.
|
|
10
|
+
|
|
11
|
+
Install the prebuilt [v0.3.1 release](https://github.com/Canary-Builds/dhs-connect/releases/tag/v0.3.1); no npm account or plugin build step is required:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
dsh plugin --profile web add https://github.com/Canary-Builds/dhs-connect/releases/download/v0.3.1/canary-builds-dsh-connect-0.3.1.tgz
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Use `--profile headless` instead for the CLI profile. Release assets include `SHA256SUMS` for checking downloaded packages. npm publication is separate; the scoped name is reserved in this source as `@canary-builds/dsh-connect`, but npm availability is not implied.
|
|
18
|
+
|
|
19
|
+
Restart your running Harness instance after installation. Open **Settings → DSH Connect**, sign in with ChatGPT, and select a model from the normal model picker. Install only the profiles you use. A pnpm workspace-root profile may require its existing `ignoreWorkspaceRootCheck` setting or the package-manager workspace-root option.
|
|
20
|
+
|
|
21
|
+
Remove the earlier `dsh-plugin-codex-astra` or `dsh-openai-oauth` adapter from a profile before installing this one: both claim the same `openai-codex` provider route. Keep your existing Codex sign-in directory to preserve authentication.
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- Account model discovery merged with a curated fallback; unlisted model IDs can still be requested.
|
|
26
|
+
- One catalog shared by Settings and the model picker, with refresh and connection diagnostics.
|
|
27
|
+
- Text streaming, reasoning summaries, Harness dynamic tool calls and token accounting.
|
|
28
|
+
- Request cancellation, RPC timeouts, process recovery and separate auxiliary calls.
|
|
29
|
+
- Conversation reconstruction after restart, compaction or changed tool definitions.
|
|
30
|
+
- No runtime npm dependencies or automatic binary downloads.
|
|
31
|
+
|
|
32
|
+
Harness owns tool execution and permissions. The connector disables native Codex tools and delegation features, and does not silently substitute a different model.
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
By default DSH Connect finds `codex` on `PATH`, and uses `~/.deepseek-harness/codex` as its sign-in directory. Set `DSH_CODEX_HOME` to an existing Codex home if you want to reuse that login.
|
|
37
|
+
|
|
38
|
+
Environment variables:
|
|
39
|
+
|
|
40
|
+
| Variable | Purpose |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `DSH_CODEX_BIN` | Absolute path to the official Codex executable; overrides discovery. |
|
|
43
|
+
| `DSH_CODEX_HOME` | Codex sign-in and configuration directory. |
|
|
44
|
+
| `DSH_CONNECT_CONFIG` | Override the configuration-file path. |
|
|
45
|
+
| `DSH_CONNECT_NO_PROXY` | Optional domains to append to the Codex child's `NO_PROXY`. |
|
|
46
|
+
|
|
47
|
+
Alternatively, create `$DSH_HOME/connect.json` (default `~/.dsh/connect.json`):
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"codexBin": "/absolute/path/to/codex",
|
|
52
|
+
"codexHome": "/absolute/path/to/codex-home"
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The optional `noProxy` field contains a comma-separated domain list. Proxy routing is inherited unchanged unless explicitly configured. Do not put credentials in this file; the official Codex executable manages authentication.
|
|
57
|
+
|
|
58
|
+
For a browser on another machine, forward the callback port before signing in:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
ssh -N -o ExitOnForwardFailure=yes -L 1455:127.0.0.1:1455 user@harness-host
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Replace `user@harness-host` with your SSH destination. The bundled `dsh-connect-login` command also supports Codex's `--device-auth` option when available to your account. Run `dsh-connect-doctor` through your profile's executable environment for diagnostics.
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
git clone https://github.com/Canary-Builds/dhs-connect.git
|
|
70
|
+
cd dhs-connect
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npm test
|
|
76
|
+
npm run test:package
|
|
77
|
+
npm run doctor
|
|
78
|
+
npm run test:live
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
No dependency installation is required for unit tests. `npm test` rebuilds the client and exercises transport failures, cancellation, model fallback, tool bridging, history recovery, Settings registration and request-origin checks. The build derives the client module ID from `package.json`; edit `src/client.js`, not `lib/client.js`.
|
|
82
|
+
|
|
83
|
+
`test:live` uses your configured ChatGPT login and sends a few short requests. Set `DSH_CONNECT_TEST_MODEL` to choose the test model. `scripts/verify-web.mjs` checks a running DSH server's served module using its actual module loader and a React render check; it uses `DSH_WEB_URL` and optionally `DSH_RUNTIME_PACKAGE` for the host location.
|
|
84
|
+
|
|
85
|
+
## Limits and security
|
|
86
|
+
|
|
87
|
+
This version supports text and text tool results. Image attachments and per-turn temperature, stop sequences or output-token caps are not supported. A model's presence in the catalog does not guarantee account access.
|
|
88
|
+
|
|
89
|
+
Matching continuations reuse an ephemeral Codex thread. When that state no longer matches the supplied DSH history, the connector rebuilds from a role-labeled JSON transcript. This adds prompt tokens and does not restore hidden reasoning or replay completed tool execution.
|
|
90
|
+
|
|
91
|
+
Settings controls require loopback transport and same-origin requests and reject cross-site requests. Remote access requires a trusted deployment proxy; the plugin does not create public endpoints or add authentication to a proxy. Native stderr is not forwarded to Harness logs because it can include request data. Diagnostics filter common credential patterns.
|
|
92
|
+
|
|
93
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for changes, [RELEASING.md](RELEASING.md) for versioned releases and npm publishing, and [DIRECTORY.md](DIRECTORY.md) for a ready-to-copy directory submission.
|
|
94
|
+
|
|
95
|
+
## Upstream references
|
|
96
|
+
|
|
97
|
+
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
|
|
98
|
+
- [Codex app-server protocol](https://learn.chatgpt.com/docs/app-server)
|
|
99
|
+
- [DSH contribution guidance](https://github.com/deepseek-ai/deepseek-harness/blob/master/CONTRIBUTING.md)
|
|
100
|
+
|
|
101
|
+
The development history began with the community [dsh-openai-oauth](https://github.com/DGPisces/dsh-openai-oauth) integration. The connector implementation in this repository was subsequently rewritten. DSH Connect is independently maintained by Canary Builds and is not an official DeepSeek or OpenAI product.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT. See [LICENSE](LICENSE).
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Generated by npm run build. Edit src/client.js.
|
|
2
|
+
window.__ModuleLoader__.load({id:"@canary-builds/dsh-connect",factory:(require)=>{
|
|
3
|
+
// Factory body compiled by scripts/build.mjs. No Node imports or browser bundler.
|
|
4
|
+
const React = require('react');
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
const inject = ['slots'];
|
|
7
|
+
const styles = {
|
|
8
|
+
root: { maxWidth: 820, display: 'flex', flexDirection: 'column', gap: 20, color: 'var(--dsw-alias-label-primary)' },
|
|
9
|
+
card: { padding: 20, border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 12, background: 'var(--dsw-alias-bg-layer-3)' },
|
|
10
|
+
row: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 },
|
|
11
|
+
muted: { color: 'var(--dsw-alias-label-secondary)', lineHeight: 1.6 },
|
|
12
|
+
button: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, padding: '8px 14px', background: 'var(--dsw-alias-bg-layer-1)', color: 'inherit', cursor: 'pointer' },
|
|
13
|
+
table: { width: '100%', borderCollapse: 'collapse', textAlign: 'left', lineHeight: 2.1 },
|
|
14
|
+
code: { fontFamily: 'monospace', fontSize: 12, overflowWrap: 'anywhere' },
|
|
15
|
+
issue: { color: 'var(--dsw-alias-state-error-primary, #c44)', lineHeight: 1.5 },
|
|
16
|
+
};
|
|
17
|
+
async function request(path = '', method = 'GET', signal) {
|
|
18
|
+
const response = await fetch('/api/dsh-connect' + path, { method, credentials: 'same-origin', headers: { accept: 'application/json' }, signal });
|
|
19
|
+
const body = await response.json();
|
|
20
|
+
if (!response.ok) throw new Error(body.error || 'Codex connection is unavailable.');
|
|
21
|
+
return body;
|
|
22
|
+
}
|
|
23
|
+
function CodexSettings() {
|
|
24
|
+
const [state, setState] = React.useState(null);
|
|
25
|
+
const [busy, setBusy] = React.useState(false);
|
|
26
|
+
const [error, setError] = React.useState('');
|
|
27
|
+
const [waiting, setWaiting] = React.useState(false);
|
|
28
|
+
const [authUrl, setAuthUrl] = React.useState('');
|
|
29
|
+
const load = React.useCallback(async signal => {
|
|
30
|
+
const next = await request('', 'GET', signal);
|
|
31
|
+
setState(next);
|
|
32
|
+
if (next.authenticated) { setWaiting(false); setAuthUrl(''); }
|
|
33
|
+
return next;
|
|
34
|
+
}, []);
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
load(controller.signal).catch(e => { if (!controller.signal.aborted) setError(e.message); });
|
|
38
|
+
return () => controller.abort();
|
|
39
|
+
}, [load]);
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
if (!waiting) return;
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
let timer;
|
|
44
|
+
const poll = async () => {
|
|
45
|
+
try { const next = await load(controller.signal); if (next.authenticated) return; }
|
|
46
|
+
catch (e) { if (!controller.signal.aborted) setError(e.message); }
|
|
47
|
+
if (!controller.signal.aborted) timer = setTimeout(poll, 2000);
|
|
48
|
+
};
|
|
49
|
+
timer = setTimeout(poll, 2000);
|
|
50
|
+
const expiry = setTimeout(() => { setWaiting(false); setAuthUrl(''); setError('Sign-in timed out. Check the SSH tunnel and start again.'); }, 5 * 60_000);
|
|
51
|
+
return () => { controller.abort(); clearTimeout(timer); clearTimeout(expiry); };
|
|
52
|
+
}, [waiting, load]);
|
|
53
|
+
async function act(path) {
|
|
54
|
+
setBusy(true); setError('');
|
|
55
|
+
const popup = path === '/login' ? window.open('about:blank', '_blank') : null;
|
|
56
|
+
if (popup) popup.opener = null;
|
|
57
|
+
try {
|
|
58
|
+
const result = await request(path, 'POST');
|
|
59
|
+
if (path === '/login') {
|
|
60
|
+
setAuthUrl(result.authUrl); setWaiting(true);
|
|
61
|
+
if (popup) popup.location.href = result.authUrl;
|
|
62
|
+
} else { setState(path === '/refresh' ? result : await load()); }
|
|
63
|
+
} catch (e) { popup?.close(); setError(e.message); }
|
|
64
|
+
finally { setBusy(false); }
|
|
65
|
+
}
|
|
66
|
+
const button = (label, action, extra = {}) => h('button', { type: 'button', style: styles.button, disabled: busy, onClick: action, ...extra }, label);
|
|
67
|
+
return h('section', { style: styles.root },
|
|
68
|
+
h('div', null, h('h2', { style: { marginBottom: 8 } }, 'DSH Connect'), h('p', { style: styles.muted }, 'Your ChatGPT models, connected to DeepSeek Harness.')),
|
|
69
|
+
h('div', { style: styles.card },
|
|
70
|
+
h('div', { style: styles.row, role: 'status', 'aria-live': 'polite' },
|
|
71
|
+
h('strong', null, state ? state.authenticated ? 'Connected' : state.connectionIssue ? 'Connection unavailable' : 'Sign-in required' : 'Checking connection…'),
|
|
72
|
+
state?.planType ? h('span', { style: styles.muted }, state.planType) : null),
|
|
73
|
+
state ? h('p', { style: styles.muted }, `Plugin ${state.pluginVersion} · ${state.codexVersion}`) : null,
|
|
74
|
+
h('div', { style: styles.row },
|
|
75
|
+
state?.authenticated ? button('Sign out', () => act('/logout')) : button(waiting ? 'Waiting for sign-in…' : 'Sign in with ChatGPT', () => act('/login'), { disabled: busy || waiting }),
|
|
76
|
+
button('Refresh connection & models', () => act('/refresh')),
|
|
77
|
+
waiting ? button('Stop waiting', () => { setWaiting(false); setAuthUrl(''); }) : null),
|
|
78
|
+
authUrl ? h('p', null, h('a', { href: authUrl, target: '_blank', rel: 'noopener noreferrer' }, 'Continue sign-in')) : null,
|
|
79
|
+
error || state?.connectionIssue ? h('p', { style: styles.issue, role: 'alert' }, error || state.connectionIssue) : null),
|
|
80
|
+
!state?.authenticated ? h('div', { style: styles.card },
|
|
81
|
+
h('strong', null, 'Signing in from another computer'),
|
|
82
|
+
h('p', { style: styles.muted }, 'Keep this SSH tunnel open on the computer running your browser before signing in. It forwards the login callback to the agent VM.'),
|
|
83
|
+
h('code', { style: styles.code }, 'ssh -N -o ExitOnForwardFailure=yes -L 1455:127.0.0.1:1455 user@harness-host'),
|
|
84
|
+
h('p', { style: styles.muted }, 'For terminal sign-in on the server: dsh-connect-login --device-auth')) : null,
|
|
85
|
+
h('div', { style: styles.card },
|
|
86
|
+
h('h3', { style: { marginTop: 0 } }, 'Models'),
|
|
87
|
+
h('p', { style: styles.muted }, state ? `Source: ${state.modelSource}. Account access is checked when you send a message.` : 'Loading model catalog…'),
|
|
88
|
+
state?.discoveryIssue ? h('p', { style: styles.muted }, state.discoveryIssue) : null,
|
|
89
|
+
h('table', { style: styles.table }, h('thead', null, h('tr', null, h('th', null, 'Model'), h('th', null, 'Model ID'))), h('tbody', null,
|
|
90
|
+
...(state?.models ?? []).map(model => h('tr', { key: model.id }, h('td', null, model.name), h('td', { style: styles.code }, model.id)))))),
|
|
91
|
+
h('p', { style: styles.muted }, 'Supports text, reasoning, and Harness tool calls. Image attachments are not supported by this provider yet.')
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
function apply(ctx) {
|
|
95
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-connect', label: () => 'DSH Connect', order: 11 }, CodexSettings));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { inject, apply, CodexSettings };
|
|
99
|
+
}});
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@canary-builds/dsh-connect",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Connect DeepSeek Harness to ChatGPT models through the Codex app-server",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Canary Builds",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Canary-Builds/dhs-connect.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Canary-Builds/dhs-connect#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Canary-Builds/dhs-connect/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"dsh-plugin",
|
|
17
|
+
"deepseek-harness",
|
|
18
|
+
"codex",
|
|
19
|
+
"connector"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "src/index.js",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./src/index.js",
|
|
25
|
+
"./client": "./lib/client.js",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"dsh-connect-login": "src/login.js",
|
|
30
|
+
"dsh-connect-doctor": "src/doctor.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"lib",
|
|
35
|
+
"cordis.patch.yml",
|
|
36
|
+
"README.md",
|
|
37
|
+
"CHANGELOG.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "node scripts/build.mjs",
|
|
42
|
+
"pretest": "node scripts/build.mjs",
|
|
43
|
+
"prepack": "node scripts/build.mjs",
|
|
44
|
+
"test": "node --test test/*.test.js",
|
|
45
|
+
"doctor": "node src/doctor.js",
|
|
46
|
+
"test:live": "node scripts/live-check.mjs",
|
|
47
|
+
"test:package": "node scripts/check-package.mjs"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=22.19.0"
|
|
51
|
+
},
|
|
52
|
+
"dsh": {
|
|
53
|
+
"bundle": {
|
|
54
|
+
"patch": "./cordis.patch.yml"
|
|
55
|
+
},
|
|
56
|
+
"client": {
|
|
57
|
+
"inject": [
|
|
58
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
60
|
+
],
|
|
61
|
+
"platform": "web"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"publishConfig": {
|
|
65
|
+
"access": "public",
|
|
66
|
+
"registry": "https://registry.npmjs.org/"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/adapter.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { AppServer } from './transport.js';
|
|
3
|
+
import { ModelCatalog } from './models.js';
|
|
4
|
+
import { CodexError } from './errors.js';
|
|
5
|
+
import { PROVIDER } from './runtime.js';
|
|
6
|
+
|
|
7
|
+
const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
|
+
const transcriptShape = messages => messages.map(m => ({ role: m.role, content: m.content }));
|
|
9
|
+
const historyHash = messages => hash(transcriptShape(messages));
|
|
10
|
+
const contentHash = content => hash(content.filter(b => !(b.type === 'text' || b.type === 'reasoning') || b.text));
|
|
11
|
+
const keyOf = options => options.sessionId ? `${options.sessionId}:${options.purpose ?? 'conversation'}` : randomUUID();
|
|
12
|
+
|
|
13
|
+
export function contentText(blocks) {
|
|
14
|
+
return blocks.map(block => {
|
|
15
|
+
if (block.type === 'text') return block.text;
|
|
16
|
+
if (block.type === 'reasoning') return '';
|
|
17
|
+
if (block.type === 'tool-call') return `[Tool call ${block.id}: ${block.name}] ${block.arguments}`;
|
|
18
|
+
if (block.type === 'tool-result') return `[${block.isError ? 'Failed tool result' : 'Tool result'} ${block.toolCallId}]\n${contentText(block.content)}`;
|
|
19
|
+
throw new CodexError(`DSH Connect currently accepts text and text tool results; unsupported content: ${block.type}.`, 'UNSUPPORTED_CONTENT');
|
|
20
|
+
}).filter(Boolean).join('\n');
|
|
21
|
+
}
|
|
22
|
+
export function inputFor(messages, rebuild = false) {
|
|
23
|
+
if (messages.length === 1 && messages[0].role === 'user' && !messages[0].content.some(b => b.type === 'tool-result')) return contentText(messages[0].content);
|
|
24
|
+
const transcript = messages.map(m => ({ role: m.role, source: m.source?.kind ?? m.role, content: contentText(m.content) }));
|
|
25
|
+
return (rebuild ? 'Harness conversation history follows as JSON. Continue from its last message. Tool results in this history have already been executed; do not repeat a tool merely to reconstruct history.\n' : 'New Harness messages follow as JSON, in order:\n') + JSON.stringify(transcript);
|
|
26
|
+
}
|
|
27
|
+
export function toolSpecs(tools = []) {
|
|
28
|
+
const names = new Set();
|
|
29
|
+
return tools.map(tool => {
|
|
30
|
+
if (typeof tool.name !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(tool.name) || names.has(tool.name)) throw new CodexError('Dynamic tool names must be unique and contain 1–64 letters, digits, underscores or hyphens.', 'INVALID_REQUEST');
|
|
31
|
+
names.add(tool.name);
|
|
32
|
+
if (!tool.parameters || tool.parameters.type !== 'object' || Array.isArray(tool.parameters)) throw new CodexError(`Tool ${tool.name} requires an object JSON Schema.`, 'INVALID_REQUEST');
|
|
33
|
+
return { type: 'function', name: tool.name, description: tool.description ?? '', inputSchema: tool.parameters };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function usageDelta(total, previous = {}) {
|
|
37
|
+
const diff = name => Math.max(0, (total[name] ?? 0) - (previous[name] ?? 0));
|
|
38
|
+
return { inputTokens: Math.max(0, diff('inputTokens') - diff('cachedInputTokens') - diff('cacheWriteInputTokens')), outputTokens: diff('outputTokens'), cacheReadTokens: diff('cachedInputTokens'), cacheWriteTokens: diff('cacheWriteInputTokens'), reasoningTokens: diff('reasoningOutputTokens') };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Implements the host's public adapter interface without a duplicate DSH runtime.
|
|
42
|
+
export class CodexAdapter {
|
|
43
|
+
sessions = new Map();
|
|
44
|
+
busy = new Set();
|
|
45
|
+
constructor(server = new AppServer(), catalog = new ModelCatalog(server), { idleTimeoutMs = 300_000, maxSessions = 128 } = {}) {
|
|
46
|
+
this.server = server;
|
|
47
|
+
this.catalog = catalog;
|
|
48
|
+
this.idleTimeoutMs = idleTimeoutMs;
|
|
49
|
+
this.maxSessions = maxSessions;
|
|
50
|
+
}
|
|
51
|
+
providerInfo() { return { id: PROVIDER, name: 'DSH Connect · ChatGPT' }; }
|
|
52
|
+
providerRetryPolicy() {}
|
|
53
|
+
listModels() { return this.catalog.list(); }
|
|
54
|
+
resolveModel(provider, model, signal) { return this.catalog.resolve(provider, model, signal); }
|
|
55
|
+
async prepareCall(provider, model, signal) { return { model: await this.resolveModel(provider, model, signal), stream: options => this.stream(options) }; }
|
|
56
|
+
async clear() {
|
|
57
|
+
const sessions = [...this.sessions.values()];
|
|
58
|
+
this.sessions.clear();
|
|
59
|
+
await Promise.all(sessions.map(s => this.server.release(s.threadId, s.turnId)));
|
|
60
|
+
}
|
|
61
|
+
reusable(s, options, signature) {
|
|
62
|
+
if (!s || s.generation !== this.server.generation || s.signature !== signature) return false;
|
|
63
|
+
const index = options.messages.findLastIndex(m => m.role === 'assistant');
|
|
64
|
+
const previous = options.messages[index];
|
|
65
|
+
const replay = previous?.source?.replayState?.response;
|
|
66
|
+
return index === s.inputCount && replay?.kind === 'dsh-connect' && replay.marker === s.marker && historyHash(options.messages.slice(0, index)) === s.inputHash && contentHash(previous.content) === s.outputHash;
|
|
67
|
+
}
|
|
68
|
+
async fresh(options, specs, signature, key) {
|
|
69
|
+
const old = this.sessions.get(key);
|
|
70
|
+
if (old) await this.server.release(old.threadId, old.turnId);
|
|
71
|
+
this.sessions.delete(key);
|
|
72
|
+
for (const [id, session] of this.sessions) {
|
|
73
|
+
if (!this.busy.has(id) && (Date.now() - session.touched > 30 * 60_000 || this.sessions.size >= this.maxSessions)) {
|
|
74
|
+
this.sessions.delete(id);
|
|
75
|
+
await this.server.release(session.threadId, session.turnId);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (this.sessions.size >= this.maxSessions) throw new CodexError('All Codex sessions are busy; retry after a running turn finishes.', 'CODEX_BUSY');
|
|
79
|
+
const threadId = await this.server.startThread({
|
|
80
|
+
model: options.model, cwd: process.cwd(), approvalPolicy: 'never', sandbox: 'read-only', ephemeral: true,
|
|
81
|
+
baseInstructions: [options.system ?? '', 'You are the model inside DeepSeek Harness. Harness owns the conversation and tool execution. Use only the supplied dynamic tools, one at a time. Never use native Codex tools.'].filter(Boolean).join('\n\n'),
|
|
82
|
+
dynamicTools: specs,
|
|
83
|
+
}, options.signal);
|
|
84
|
+
const session = { threadId, generation: this.server.generation, signature, touched: Date.now(), pending: [], billed: {}, turnId: undefined };
|
|
85
|
+
this.sessions.set(key, session);
|
|
86
|
+
return session;
|
|
87
|
+
}
|
|
88
|
+
async *stream(options) {
|
|
89
|
+
options.signal?.throwIfAborted();
|
|
90
|
+
if (options.stop !== undefined || options.temperature !== undefined || options.maxTokens !== undefined) throw new CodexError('Codex app-server does not support stop, temperature, or maxTokens overrides.', 'UNSUPPORTED_OPTION');
|
|
91
|
+
if (!options.messages?.length) throw new CodexError('At least one conversation message is required.', 'INVALID_REQUEST');
|
|
92
|
+
for (const message of options.messages) contentText(message.content); // Validate nested content before starting anything.
|
|
93
|
+
const specs = toolSpecs(options.tools);
|
|
94
|
+
const signature = hash({ system: options.system, specs, model: options.model, effort: options.reasoningEffort, cwd: process.cwd() });
|
|
95
|
+
const key = keyOf(options);
|
|
96
|
+
if (this.busy.has(key)) throw new CodexError('A Codex response is already running for this Harness session.', 'CODEX_BUSY');
|
|
97
|
+
this.busy.add(key);
|
|
98
|
+
let session;
|
|
99
|
+
let finished = false;
|
|
100
|
+
try {
|
|
101
|
+
await this.server.start(options.signal);
|
|
102
|
+
const account = await this.server.account(options.signal);
|
|
103
|
+
if (account?.type !== 'chatgpt') throw new CodexError('Sign in with ChatGPT in Settings → DSH Connect, or run dsh-connect-login.', 'MISSING_CREDENTIAL');
|
|
104
|
+
session = this.sessions.get(key);
|
|
105
|
+
const reuse = this.reusable(session, options, signature);
|
|
106
|
+
if (!reuse) session = await this.fresh(options, specs, signature, key);
|
|
107
|
+
if (session.pending.length) {
|
|
108
|
+
const results = new Map(options.messages.flatMap(m => m.content).filter(b => b.type === 'tool-result').map(b => [b.toolCallId, b]));
|
|
109
|
+
for (const call of session.pending) {
|
|
110
|
+
const result = results.get(call.callId);
|
|
111
|
+
if (!result) throw new CodexError('Harness has not supplied the pending tool result.', 'INVALID_REQUEST');
|
|
112
|
+
this.server.respond(call.requestId, { contentItems: [{ type: 'inputText', text: contentText(result.content) || '(no output)' }], success: !result.isError });
|
|
113
|
+
}
|
|
114
|
+
session.pending = [];
|
|
115
|
+
} else {
|
|
116
|
+
const incoming = reuse ? options.messages.slice(session.inputCount + 1) : options.messages;
|
|
117
|
+
if (!incoming.length) throw new CodexError('No new Harness messages were supplied.', 'INVALID_REQUEST');
|
|
118
|
+
session.turnId = await this.server.startTurn(session.threadId, { model: options.model, ...(options.reasoningEffort ? { effort: options.reasoningEffort } : {}), input: [{ type: 'text', text: inputFor(incoming, !reuse) }] }, options.signal);
|
|
119
|
+
session.closedItems = new Set();
|
|
120
|
+
}
|
|
121
|
+
session.inputCount = options.messages.length;
|
|
122
|
+
session.inputHash = historyHash(options.messages);
|
|
123
|
+
const open = new Map();
|
|
124
|
+
const emitted = [];
|
|
125
|
+
let nextIndex = 0;
|
|
126
|
+
const end = block => { open.delete(block.key); session.closedItems.add(block.itemId); const content = { type: block.type, text: block.text }; emitted[block.index] = content; return { type: 'block-end', index: block.index, block: content }; };
|
|
127
|
+
const finish = kind => {
|
|
128
|
+
session.marker = randomUUID();
|
|
129
|
+
session.outputHash = contentHash(emitted.filter(Boolean));
|
|
130
|
+
session.touched = Date.now();
|
|
131
|
+
finished = true;
|
|
132
|
+
return { type: 'finish', reason: { kind }, replayState: { response: { kind: 'dsh-connect', version: 2, marker: session.marker } } };
|
|
133
|
+
};
|
|
134
|
+
while (true) {
|
|
135
|
+
const timeout = AbortSignal.timeout(this.idleTimeoutMs);
|
|
136
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
137
|
+
let event;
|
|
138
|
+
try { event = await this.server.nextEvent(session.threadId, signal); }
|
|
139
|
+
catch (error) { if (!options.signal?.aborted && timeout.aborted) throw new CodexError('Codex stopped sending events. Retry to reconnect.', 'CODEX_TIMEOUT'); throw error; }
|
|
140
|
+
const { method, params } = event;
|
|
141
|
+
const eventTurn = params.turnId ?? params.turn?.id;
|
|
142
|
+
if (eventTurn && session.turnId && eventTurn !== session.turnId) continue;
|
|
143
|
+
if (method === 'thread/tokenUsage/updated') { session.usage = params.tokenUsage?.total; continue; }
|
|
144
|
+
if (method === 'error' && !params.willRetry) throw new CodexError(params.error?.message ?? 'Codex turn failed.');
|
|
145
|
+
const type = method === 'item/agentMessage/delta' ? 'text' : method === 'item/reasoning/summaryTextDelta' ? 'reasoning' : undefined;
|
|
146
|
+
if (type) {
|
|
147
|
+
const key = `${params.itemId}:${type}`;
|
|
148
|
+
let block = open.get(key);
|
|
149
|
+
if (!block) {
|
|
150
|
+
block = { key, itemId: params.itemId, index: nextIndex++, type, text: '' };
|
|
151
|
+
open.set(key, block);
|
|
152
|
+
yield { type: 'block-start', index: block.index, blockType: type };
|
|
153
|
+
}
|
|
154
|
+
const text = params.delta ?? '';
|
|
155
|
+
block.text += text;
|
|
156
|
+
yield { type: type === 'text' ? 'text-delta' : 'reasoning-delta', index: block.index, text };
|
|
157
|
+
}
|
|
158
|
+
if (method === 'item/completed') {
|
|
159
|
+
const item = params.item;
|
|
160
|
+
const blocks = [...open.values()].filter(b => b.itemId === item?.id);
|
|
161
|
+
// Some app-server paths send only the final item, with no deltas.
|
|
162
|
+
if (!blocks.length && item?.type === 'agentMessage' && item.text && !session.closedItems.has(item.id)) {
|
|
163
|
+
const block = { key: `${item.id}:text`, itemId: item.id, index: nextIndex++, type: 'text', text: item.text };
|
|
164
|
+
yield { type: 'block-start', index: block.index, blockType: 'text' };
|
|
165
|
+
yield { type: 'text-delta', index: block.index, text: item.text };
|
|
166
|
+
yield end(block);
|
|
167
|
+
}
|
|
168
|
+
for (const block of blocks) yield end(block);
|
|
169
|
+
}
|
|
170
|
+
if (method === 'item/tool/call') {
|
|
171
|
+
const { callId, tool, arguments: args } = params;
|
|
172
|
+
if (event.requestId === undefined || typeof callId !== 'string' || !specs.some(t => t.name === tool)) {
|
|
173
|
+
if (event.requestId !== undefined) this.server.respond(event.requestId, { success: false, contentItems: [{ type: 'inputText', text: 'Unknown or malformed Harness tool call.' }] });
|
|
174
|
+
throw new CodexError('Codex requested an unknown or malformed dynamic tool.', 'PROTOCOL_ERROR');
|
|
175
|
+
}
|
|
176
|
+
for (const block of [...open.values()]) yield end(block);
|
|
177
|
+
const index = nextIndex++;
|
|
178
|
+
const content = { type: 'tool-call', id: callId, name: tool, arguments: JSON.stringify(args ?? {}) };
|
|
179
|
+
session.pending = [{ requestId: event.requestId, callId }];
|
|
180
|
+
emitted[index] = content;
|
|
181
|
+
yield { type: 'block-start', index, blockType: 'tool-call' };
|
|
182
|
+
yield { type: 'tool-call-delta', index, id: callId, name: tool, argumentsDelta: content.arguments };
|
|
183
|
+
yield { type: 'block-end', index, block: content };
|
|
184
|
+
if (session.usage) { yield { type: 'usage', usage: usageDelta(session.usage, session.billed) }; session.billed = session.usage; }
|
|
185
|
+
yield finish('tool-calls');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (method === 'turn/completed') {
|
|
189
|
+
if (params.turn?.status === 'failed') throw new CodexError(params.turn.error?.message ?? 'Codex turn failed.');
|
|
190
|
+
if (params.turn?.status === 'interrupted') throw new CodexError('Codex turn was interrupted.', 'ABORTED');
|
|
191
|
+
for (const block of [...open.values()]) yield end(block);
|
|
192
|
+
if (session.usage) { yield { type: 'usage', usage: usageDelta(session.usage, session.billed) }; session.billed = session.usage; }
|
|
193
|
+
session.turnId = undefined;
|
|
194
|
+
yield finish('stop');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
if (session && (!finished || !options.sessionId || options.purpose)) {
|
|
200
|
+
this.sessions.delete(key);
|
|
201
|
+
await this.server.release(session.threadId, session.turnId);
|
|
202
|
+
}
|
|
203
|
+
this.busy.delete(key);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Factory body compiled by scripts/build.mjs. No Node imports or browser bundler.
|
|
2
|
+
const React = require('react');
|
|
3
|
+
const h = React.createElement;
|
|
4
|
+
const inject = ['slots'];
|
|
5
|
+
const styles = {
|
|
6
|
+
root: { maxWidth: 820, display: 'flex', flexDirection: 'column', gap: 20, color: 'var(--dsw-alias-label-primary)' },
|
|
7
|
+
card: { padding: 20, border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 12, background: 'var(--dsw-alias-bg-layer-3)' },
|
|
8
|
+
row: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 },
|
|
9
|
+
muted: { color: 'var(--dsw-alias-label-secondary)', lineHeight: 1.6 },
|
|
10
|
+
button: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, padding: '8px 14px', background: 'var(--dsw-alias-bg-layer-1)', color: 'inherit', cursor: 'pointer' },
|
|
11
|
+
table: { width: '100%', borderCollapse: 'collapse', textAlign: 'left', lineHeight: 2.1 },
|
|
12
|
+
code: { fontFamily: 'monospace', fontSize: 12, overflowWrap: 'anywhere' },
|
|
13
|
+
issue: { color: 'var(--dsw-alias-state-error-primary, #c44)', lineHeight: 1.5 },
|
|
14
|
+
};
|
|
15
|
+
async function request(path = '', method = 'GET', signal) {
|
|
16
|
+
const response = await fetch('/api/dsh-connect' + path, { method, credentials: 'same-origin', headers: { accept: 'application/json' }, signal });
|
|
17
|
+
const body = await response.json();
|
|
18
|
+
if (!response.ok) throw new Error(body.error || 'Codex connection is unavailable.');
|
|
19
|
+
return body;
|
|
20
|
+
}
|
|
21
|
+
function CodexSettings() {
|
|
22
|
+
const [state, setState] = React.useState(null);
|
|
23
|
+
const [busy, setBusy] = React.useState(false);
|
|
24
|
+
const [error, setError] = React.useState('');
|
|
25
|
+
const [waiting, setWaiting] = React.useState(false);
|
|
26
|
+
const [authUrl, setAuthUrl] = React.useState('');
|
|
27
|
+
const load = React.useCallback(async signal => {
|
|
28
|
+
const next = await request('', 'GET', signal);
|
|
29
|
+
setState(next);
|
|
30
|
+
if (next.authenticated) { setWaiting(false); setAuthUrl(''); }
|
|
31
|
+
return next;
|
|
32
|
+
}, []);
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
load(controller.signal).catch(e => { if (!controller.signal.aborted) setError(e.message); });
|
|
36
|
+
return () => controller.abort();
|
|
37
|
+
}, [load]);
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
if (!waiting) return;
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
let timer;
|
|
42
|
+
const poll = async () => {
|
|
43
|
+
try { const next = await load(controller.signal); if (next.authenticated) return; }
|
|
44
|
+
catch (e) { if (!controller.signal.aborted) setError(e.message); }
|
|
45
|
+
if (!controller.signal.aborted) timer = setTimeout(poll, 2000);
|
|
46
|
+
};
|
|
47
|
+
timer = setTimeout(poll, 2000);
|
|
48
|
+
const expiry = setTimeout(() => { setWaiting(false); setAuthUrl(''); setError('Sign-in timed out. Check the SSH tunnel and start again.'); }, 5 * 60_000);
|
|
49
|
+
return () => { controller.abort(); clearTimeout(timer); clearTimeout(expiry); };
|
|
50
|
+
}, [waiting, load]);
|
|
51
|
+
async function act(path) {
|
|
52
|
+
setBusy(true); setError('');
|
|
53
|
+
const popup = path === '/login' ? window.open('about:blank', '_blank') : null;
|
|
54
|
+
if (popup) popup.opener = null;
|
|
55
|
+
try {
|
|
56
|
+
const result = await request(path, 'POST');
|
|
57
|
+
if (path === '/login') {
|
|
58
|
+
setAuthUrl(result.authUrl); setWaiting(true);
|
|
59
|
+
if (popup) popup.location.href = result.authUrl;
|
|
60
|
+
} else { setState(path === '/refresh' ? result : await load()); }
|
|
61
|
+
} catch (e) { popup?.close(); setError(e.message); }
|
|
62
|
+
finally { setBusy(false); }
|
|
63
|
+
}
|
|
64
|
+
const button = (label, action, extra = {}) => h('button', { type: 'button', style: styles.button, disabled: busy, onClick: action, ...extra }, label);
|
|
65
|
+
return h('section', { style: styles.root },
|
|
66
|
+
h('div', null, h('h2', { style: { marginBottom: 8 } }, 'DSH Connect'), h('p', { style: styles.muted }, 'Your ChatGPT models, connected to DeepSeek Harness.')),
|
|
67
|
+
h('div', { style: styles.card },
|
|
68
|
+
h('div', { style: styles.row, role: 'status', 'aria-live': 'polite' },
|
|
69
|
+
h('strong', null, state ? state.authenticated ? 'Connected' : state.connectionIssue ? 'Connection unavailable' : 'Sign-in required' : 'Checking connection…'),
|
|
70
|
+
state?.planType ? h('span', { style: styles.muted }, state.planType) : null),
|
|
71
|
+
state ? h('p', { style: styles.muted }, `Plugin ${state.pluginVersion} · ${state.codexVersion}`) : null,
|
|
72
|
+
h('div', { style: styles.row },
|
|
73
|
+
state?.authenticated ? button('Sign out', () => act('/logout')) : button(waiting ? 'Waiting for sign-in…' : 'Sign in with ChatGPT', () => act('/login'), { disabled: busy || waiting }),
|
|
74
|
+
button('Refresh connection & models', () => act('/refresh')),
|
|
75
|
+
waiting ? button('Stop waiting', () => { setWaiting(false); setAuthUrl(''); }) : null),
|
|
76
|
+
authUrl ? h('p', null, h('a', { href: authUrl, target: '_blank', rel: 'noopener noreferrer' }, 'Continue sign-in')) : null,
|
|
77
|
+
error || state?.connectionIssue ? h('p', { style: styles.issue, role: 'alert' }, error || state.connectionIssue) : null),
|
|
78
|
+
!state?.authenticated ? h('div', { style: styles.card },
|
|
79
|
+
h('strong', null, 'Signing in from another computer'),
|
|
80
|
+
h('p', { style: styles.muted }, 'Keep this SSH tunnel open on the computer running your browser before signing in. It forwards the login callback to the agent VM.'),
|
|
81
|
+
h('code', { style: styles.code }, 'ssh -N -o ExitOnForwardFailure=yes -L 1455:127.0.0.1:1455 user@harness-host'),
|
|
82
|
+
h('p', { style: styles.muted }, 'For terminal sign-in on the server: dsh-connect-login --device-auth')) : null,
|
|
83
|
+
h('div', { style: styles.card },
|
|
84
|
+
h('h3', { style: { marginTop: 0 } }, 'Models'),
|
|
85
|
+
h('p', { style: styles.muted }, state ? `Source: ${state.modelSource}. Account access is checked when you send a message.` : 'Loading model catalog…'),
|
|
86
|
+
state?.discoveryIssue ? h('p', { style: styles.muted }, state.discoveryIssue) : null,
|
|
87
|
+
h('table', { style: styles.table }, h('thead', null, h('tr', null, h('th', null, 'Model'), h('th', null, 'Model ID'))), h('tbody', null,
|
|
88
|
+
...(state?.models ?? []).map(model => h('tr', { key: model.id }, h('td', null, model.name), h('td', { style: styles.code }, model.id)))))),
|
|
89
|
+
h('p', { style: styles.muted }, 'Supports text, reasoning, and Harness tool calls. Image attachments are not supported by this provider yet.')
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
function apply(ctx) {
|
|
93
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-connect', label: () => 'DSH Connect', order: 11 }, CodexSettings));
|
|
94
|
+
}
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { AppServer } from './transport.js';
|
|
3
|
+
import { ModelCatalog } from './models.js';
|
|
4
|
+
import { CodexAdapter } from './adapter.js';
|
|
5
|
+
import { createController } from './http.js';
|
|
6
|
+
import { safeMessage } from './errors.js';
|
|
7
|
+
const server = new AppServer();
|
|
8
|
+
try {
|
|
9
|
+
const catalog = new ModelCatalog(server);
|
|
10
|
+
const controller = createController(server, catalog, new CodexAdapter(server, catalog));
|
|
11
|
+
const status = await controller.status(AbortSignal.timeout(60_000));
|
|
12
|
+
console.log(JSON.stringify(status, null, 2));
|
|
13
|
+
if (status.connectionIssue || !status.authenticated) process.exitCode = 1;
|
|
14
|
+
} catch (error) { console.error(safeMessage(error)); process.exitCode = 1; }
|
|
15
|
+
finally { server.close(); }
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function safeMessage(error) {
|
|
2
|
+
return String(error?.message ?? error ?? 'Codex failed')
|
|
3
|
+
.replace(/https?:\/\/[^\s"<>]*\?[^\s"<>]*/gi, '[URL query redacted]')
|
|
4
|
+
.replace(/\b(?:sk-|gh[pousr]_|tskey-|eyJ)[\w./+=-]+/g, '[redacted]')
|
|
5
|
+
.replace(/(?:bearer\s+)[^\s"']+/gi, 'Bearer [redacted]')
|
|
6
|
+
.replace(/(?:access_token|refresh_token|id_token|api_key|password)\s*["']?\s*[:=]\s*["']?[^\s,"'}]+/gi, '[credential redacted]')
|
|
7
|
+
.slice(0, 1000);
|
|
8
|
+
}
|
|
9
|
+
export class CodexError extends Error {
|
|
10
|
+
constructor(message, code = 'CODEX_ERROR') {
|
|
11
|
+
super(safeMessage(message));
|
|
12
|
+
this.name = 'CodexError';
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.failure = { message: this.message, code };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function abortable(promise, signal) {
|
|
18
|
+
if (!signal) return promise;
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const abort = () => reject(signal.reason);
|
|
22
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
23
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
24
|
+
});
|
|
25
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { runtime, VERSION } from './runtime.js';
|
|
5
|
+
import { safeMessage } from './errors.js';
|
|
6
|
+
|
|
7
|
+
export const API_PATH = '/api/dsh-connect';
|
|
8
|
+
const isLoopback = host => host === '::1' || (isIP(host) === 4 && host.startsWith('127.'));
|
|
9
|
+
export function trustedRequest(req) {
|
|
10
|
+
const remote = req.socket?.remoteAddress?.replace(/^::ffff:/, '');
|
|
11
|
+
if (!isLoopback(remote ?? '') || req.headers['sec-fetch-site'] === 'cross-site') return false;
|
|
12
|
+
try {
|
|
13
|
+
const host = new URL(`http://${req.headers.host}`);
|
|
14
|
+
const name = host.hostname.replace(/^\[|\]$/g, '');
|
|
15
|
+
if (name !== 'localhost' && !isLoopback(name)) return false;
|
|
16
|
+
return !req.headers.origin || new URL(req.headers.origin).origin === host.origin;
|
|
17
|
+
} catch { return false; }
|
|
18
|
+
}
|
|
19
|
+
export async function binaryVersion() {
|
|
20
|
+
const r = runtime();
|
|
21
|
+
const { stdout } = await promisify(execFile)(r.command, ['--version'], { env: r.env, timeout: 5000 });
|
|
22
|
+
return /^codex-cli [\w.+-]+/.exec(stdout)?.[0] ?? 'unknown';
|
|
23
|
+
}
|
|
24
|
+
export function createController(server, catalog, adapter, { version = binaryVersion } = {}) {
|
|
25
|
+
let versionPromise;
|
|
26
|
+
async function status(signal) {
|
|
27
|
+
let account;
|
|
28
|
+
let connectionIssue;
|
|
29
|
+
try { account = await server.account(signal); }
|
|
30
|
+
catch (error) { if (signal?.aborted) throw error; connectionIssue = safeMessage(error); }
|
|
31
|
+
const models = await catalog.list(signal);
|
|
32
|
+
versionPromise ??= version().catch(() => 'unavailable');
|
|
33
|
+
return {
|
|
34
|
+
pluginVersion: VERSION, codexVersion: await versionPromise, authenticated: account?.type === 'chatgpt',
|
|
35
|
+
planType: account?.type === 'chatgpt' ? account.planType ?? null : null,
|
|
36
|
+
models, modelSource: catalog.source, discoveryIssue: catalog.discoveryIssue ?? null,
|
|
37
|
+
connectionIssue: connectionIssue ?? null, activeSessions: adapter.sessions.size,
|
|
38
|
+
capabilities: ['Text streaming', 'Harness tool calls', 'Reasoning', 'Conversation recovery'],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const json = (res, code, body) => {
|
|
42
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
43
|
+
res.end(JSON.stringify(body));
|
|
44
|
+
};
|
|
45
|
+
async function handler(req, res) {
|
|
46
|
+
if (!trustedRequest(req)) return json(res, 403, { error: 'Use the trusted Harness UI to manage Codex.' });
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const disconnected = () => { if (!res.writableEnded) controller.abort(); };
|
|
49
|
+
res.on('close', disconnected);
|
|
50
|
+
try {
|
|
51
|
+
const path = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
52
|
+
if (req.method === 'GET' && path === API_PATH) return json(res, 200, await status(controller.signal));
|
|
53
|
+
if (req.method === 'POST' && path === `${API_PATH}/refresh`) {
|
|
54
|
+
await catalog.refresh(controller.signal, true);
|
|
55
|
+
return json(res, 200, await status(controller.signal));
|
|
56
|
+
}
|
|
57
|
+
if (req.method === 'POST' && path === `${API_PATH}/login`) {
|
|
58
|
+
const login = await server.request('account/login/start', { type: 'chatgpt', useHostedLoginSuccessPage: true, appBrand: 'chatgpt' }, controller.signal);
|
|
59
|
+
const url = new URL(login.authUrl);
|
|
60
|
+
if (url.protocol !== 'https:' || url.hostname !== 'auth.openai.com') throw new Error('Codex returned an unexpected sign-in address.');
|
|
61
|
+
catalog.invalidate();
|
|
62
|
+
return json(res, 200, { authUrl: login.authUrl });
|
|
63
|
+
}
|
|
64
|
+
if (req.method === 'POST' && path === `${API_PATH}/logout`) {
|
|
65
|
+
await adapter.clear();
|
|
66
|
+
await server.request('account/logout', {}, controller.signal);
|
|
67
|
+
catalog.invalidate();
|
|
68
|
+
return json(res, 200, { ok: true });
|
|
69
|
+
}
|
|
70
|
+
return json(res, 404, { error: 'Unknown DSH Connect endpoint.' });
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (!res.destroyed) return json(res, 503, { error: safeMessage(error) });
|
|
73
|
+
} finally { res.off('close', disconnected); }
|
|
74
|
+
}
|
|
75
|
+
return { status, route: { kind: 'prefix', path: API_PATH, handler } };
|
|
76
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { AppServer } from './transport.js';
|
|
2
|
+
import { ModelCatalog } from './models.js';
|
|
3
|
+
import { CodexAdapter } from './adapter.js';
|
|
4
|
+
import { createController } from './http.js';
|
|
5
|
+
import { PROVIDER } from './runtime.js';
|
|
6
|
+
|
|
7
|
+
export const name = 'llm-dsh-connect';
|
|
8
|
+
export const inject = ['llm'];
|
|
9
|
+
export function apply(ctx) {
|
|
10
|
+
const server = new AppServer();
|
|
11
|
+
const catalog = new ModelCatalog(server);
|
|
12
|
+
const adapter = new CodexAdapter(server, catalog);
|
|
13
|
+
const controller = createController(server, catalog, adapter);
|
|
14
|
+
ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
15
|
+
ctx.effect(() => () => server.close(), 'dsh-connect.shutdown');
|
|
16
|
+
ctx.inject(['webServer'], web => {
|
|
17
|
+
web.effect(() => web.webServer.register(controller.route), 'dsh-connect.settings');
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export default { name, inject, apply };
|
package/src/login.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdir } from 'node:fs/promises';
|
|
4
|
+
import { runtime } from './runtime.js';
|
|
5
|
+
import { safeMessage } from './errors.js';
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const launch = runtime();
|
|
9
|
+
await mkdir(launch.env.CODEX_HOME, { recursive: true, mode: 0o700 });
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const child = spawn(launch.command, ['login', ...args], { env: launch.env, stdio: 'inherit' });
|
|
12
|
+
child.on('error', error => { console.error(safeMessage(error)); process.exitCode = 1; });
|
|
13
|
+
child.on('exit', code => { process.exitCode = code ?? 1; });
|
|
14
|
+
} catch (error) { console.error(safeMessage(error)); process.exitCode = 1; }
|
package/src/models.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { PROVIDER } from './runtime.js';
|
|
2
|
+
import { CodexError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
const basic = ['low', 'medium', 'high', 'xhigh'];
|
|
5
|
+
// Fallback metadata was checked against the operator's working Codex catalog.
|
|
6
|
+
// Live account metadata wins; catalog membership never gates a model request.
|
|
7
|
+
export const CURATED = [
|
|
8
|
+
['gpt-6-astra', 'GPT-6 Astra', [...basic, 'max']],
|
|
9
|
+
['gpt-5.6-sol', 'GPT-5.6 Sol', [...basic, 'max']],
|
|
10
|
+
['gpt-5.6-terra', 'GPT-5.6 Terra', [...basic, 'max']],
|
|
11
|
+
['gpt-5.6-luna', 'GPT-5.6 Luna', [...basic, 'max']],
|
|
12
|
+
['gpt-5.5', 'GPT-5.5', basic],
|
|
13
|
+
['gpt-5.2', 'GPT-5.2', basic],
|
|
14
|
+
].map(([id, name, efforts]) => ({ id, model: id, displayName: name, supportedReasoningEfforts: efforts.map(reasoningEffort => ({ reasoningEffort })), defaultReasoningEffort: 'medium' }));
|
|
15
|
+
|
|
16
|
+
export class ModelCatalog {
|
|
17
|
+
live = [];
|
|
18
|
+
expires = 0;
|
|
19
|
+
source = 'curated';
|
|
20
|
+
discoveryIssue;
|
|
21
|
+
constructor(server, { ttlMs = 60_000 } = {}) { this.server = server; this.ttlMs = ttlMs; }
|
|
22
|
+
invalidate() { this.expires = 0; this.live = []; this.source = 'curated'; }
|
|
23
|
+
async refresh(signal, force = false) {
|
|
24
|
+
if (!force && this.expires > Date.now()) return;
|
|
25
|
+
try {
|
|
26
|
+
const live = await this.server.models(signal);
|
|
27
|
+
this.live = live.filter(m => typeof (m.model ?? m.id) === 'string' && !m.hidden);
|
|
28
|
+
this.discoveryIssue = this.live.length ? undefined : 'Account discovery returned no models; curated models remain available.';
|
|
29
|
+
this.source = this.live.length ? 'account + curated' : 'curated';
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (signal?.aborted) throw error;
|
|
32
|
+
this.discoveryIssue = 'Account model discovery is unavailable; using the saved and curated list.';
|
|
33
|
+
this.source = this.live.length ? 'cached + curated' : 'curated';
|
|
34
|
+
}
|
|
35
|
+
this.expires = Date.now() + this.ttlMs;
|
|
36
|
+
}
|
|
37
|
+
entries() {
|
|
38
|
+
const merged = new Map(CURATED.map(m => [m.model, m]));
|
|
39
|
+
for (const m of this.live) merged.set(m.model ?? m.id, { ...merged.get(m.model ?? m.id), ...m });
|
|
40
|
+
return [...merged.values()];
|
|
41
|
+
}
|
|
42
|
+
info(m) {
|
|
43
|
+
return { provider: PROVIDER, id: m.model ?? m.id, name: m.displayName ?? m.model ?? m.id, inputModalities: ['text'] };
|
|
44
|
+
}
|
|
45
|
+
async list(signal) { await this.refresh(signal); return this.entries().map(m => this.info(m)); }
|
|
46
|
+
async resolve(provider, id, signal) {
|
|
47
|
+
signal?.throwIfAborted();
|
|
48
|
+
if (provider !== PROVIDER || typeof id !== 'string' || !id.trim()) throw new CodexError('A valid Codex provider and model ID are required.', 'INVALID_REQUEST');
|
|
49
|
+
await this.refresh(signal);
|
|
50
|
+
const m = this.entries().find(m => m.model === id || m.id === id) ?? { id };
|
|
51
|
+
const efforts = (m.supportedReasoningEfforts ?? []).filter(e => typeof e.reasoningEffort === 'string').map(e => ({ id: e.reasoningEffort, name: e.reasoningEffort, ...(e.description ? { description: e.description } : {}) }));
|
|
52
|
+
return { ...this.info(m), id, ...(efforts.length ? { reasoning: { efforts, ...(efforts.some(e => e.id === m.defaultReasoningEffort) ? { defaultEffort: m.defaultReasoningEffort } : {}) } } : {}) };
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { delimiter, join, resolve } from 'node:path';
|
|
4
|
+
import { CodexError } from './errors.js';
|
|
5
|
+
|
|
6
|
+
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
7
|
+
export const PROVIDER = 'openai-codex';
|
|
8
|
+
export function configuration(env = process.env) {
|
|
9
|
+
const file = env.DSH_CONNECT_CONFIG || join(env.DSH_HOME || join(homedir(), '.dsh'), 'connect.json');
|
|
10
|
+
if (!existsSync(file)) return {};
|
|
11
|
+
try {
|
|
12
|
+
const config = JSON.parse(readFileSync(file, 'utf8'));
|
|
13
|
+
if (!config || Array.isArray(config) || typeof config !== 'object') throw new Error();
|
|
14
|
+
for (const key of Object.keys(config)) {
|
|
15
|
+
if (!['codexBin', 'codexHome', 'noProxy'].includes(key) || typeof config[key] !== 'string') throw new Error();
|
|
16
|
+
}
|
|
17
|
+
return config;
|
|
18
|
+
} catch { throw new CodexError('Invalid DSH Connect configuration. Expected an object containing codexBin, codexHome or noProxy strings.', 'INVALID_CONFIG'); }
|
|
19
|
+
}
|
|
20
|
+
export function codexHome(env = process.env, config = configuration(env)) {
|
|
21
|
+
const configured = env.DSH_CODEX_HOME?.trim() || config.codexHome;
|
|
22
|
+
return configured ? resolve(configured) : join(homedir(), '.deepseek-harness', 'codex');
|
|
23
|
+
}
|
|
24
|
+
export function runtime(env = process.env) {
|
|
25
|
+
const config = configuration(env);
|
|
26
|
+
const explicit = env.DSH_CODEX_BIN?.trim() || config.codexBin;
|
|
27
|
+
const candidates = explicit ? [resolve(explicit)] : (env.PATH || '').split(delimiter).filter(Boolean).map(dir => join(dir, process.platform === 'win32' ? 'codex.exe' : 'codex'));
|
|
28
|
+
const command = candidates.find(path => { try { accessSync(path, constants.X_OK); return true; } catch { return false; } });
|
|
29
|
+
if (!command) throw new CodexError('Codex executable not found. Install the official Codex CLI or set DSH_CODEX_BIN to its executable path.', 'CODEX_BINARY_MISSING');
|
|
30
|
+
const childEnv = { ...env, CODEX_HOME: codexHome(env, config) };
|
|
31
|
+
const extraNoProxy = env.DSH_CONNECT_NO_PROXY ?? config.noProxy;
|
|
32
|
+
if (extraNoProxy) {
|
|
33
|
+
const noProxy = [...new Set([env.NO_PROXY, env.no_proxy, extraNoProxy].filter(Boolean).flatMap(s => s.split(',')).map(s => s.trim()).filter(Boolean))].join(',');
|
|
34
|
+
childEnv.NO_PROXY = noProxy;
|
|
35
|
+
childEnv.no_proxy = noProxy;
|
|
36
|
+
}
|
|
37
|
+
return { command, env: childEnv };
|
|
38
|
+
}
|
|
39
|
+
// Harness owns tool execution, its permission checks, and agent delegation.
|
|
40
|
+
export const CODEX_ARGS = [
|
|
41
|
+
...['shell_tool', 'goals', 'apps', 'browser_use', 'computer_use', 'hooks', 'image_generation', 'in_app_browser', 'multi_agent', 'plugins', 'skill_search', 'tool_suggest', 'unified_exec', 'workspace_dependencies']
|
|
42
|
+
.flatMap(feature => ['-c', `features.${feature}=false`]),
|
|
43
|
+
'-c', 'web_search="disabled"', '-c', 'agents.enabled=false', '-c', 'tools.view_image=false', '-c', 'project_doc_max_bytes=0',
|
|
44
|
+
];
|
package/src/transport.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { mkdir } from 'node:fs/promises';
|
|
4
|
+
import { abortable, CodexError } from './errors.js';
|
|
5
|
+
import { runtime, CODEX_ARGS, VERSION } from './runtime.js';
|
|
6
|
+
|
|
7
|
+
export class EventQueue {
|
|
8
|
+
values = [];
|
|
9
|
+
waiters = [];
|
|
10
|
+
error;
|
|
11
|
+
push(value) {
|
|
12
|
+
if (this.error) return;
|
|
13
|
+
if (this.waiters.length) this.waiters.shift().resolve(value);
|
|
14
|
+
else if (this.values.length >= 4096) this.fail(new CodexError('Codex event queue overflowed; retry the turn.', 'PROTOCOL_ERROR'));
|
|
15
|
+
else this.values.push(value);
|
|
16
|
+
}
|
|
17
|
+
fail(error) {
|
|
18
|
+
this.error = error;
|
|
19
|
+
this.values = [];
|
|
20
|
+
for (const waiter of this.waiters.splice(0)) waiter.reject(error);
|
|
21
|
+
}
|
|
22
|
+
take(signal) {
|
|
23
|
+
signal?.throwIfAborted();
|
|
24
|
+
if (this.error) return Promise.reject(this.error);
|
|
25
|
+
if (this.values.length) return Promise.resolve(this.values.shift());
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const finish = (fn, value) => {
|
|
28
|
+
signal?.removeEventListener('abort', abort);
|
|
29
|
+
this.waiters = this.waiters.filter(entry => entry !== waiter);
|
|
30
|
+
fn(value);
|
|
31
|
+
};
|
|
32
|
+
const waiter = { resolve: value => finish(resolve, value), reject: error => finish(reject, error) };
|
|
33
|
+
const abort = () => waiter.reject(signal.reason);
|
|
34
|
+
this.waiters.push(waiter);
|
|
35
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class AppServer {
|
|
41
|
+
child;
|
|
42
|
+
starting;
|
|
43
|
+
generation = 0;
|
|
44
|
+
nextId = 1;
|
|
45
|
+
pending = new Map();
|
|
46
|
+
queues = new Map();
|
|
47
|
+
retired = new Set();
|
|
48
|
+
lastFailure;
|
|
49
|
+
stderrBytes = 0;
|
|
50
|
+
constructor({ launch, requestTimeoutMs = 30_000 } = {}) {
|
|
51
|
+
this.launch = launch ?? (() => { const r = runtime(); return { ...r, args: [...CODEX_ARGS, 'app-server', '--stdio'], cwd: r.env.CODEX_HOME }; });
|
|
52
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
53
|
+
}
|
|
54
|
+
async start(signal) {
|
|
55
|
+
if (!this.starting) {
|
|
56
|
+
const start = this.boot();
|
|
57
|
+
this.starting = start;
|
|
58
|
+
start.catch(() => { if (this.starting === start) this.starting = undefined; });
|
|
59
|
+
}
|
|
60
|
+
return abortable(this.starting, signal);
|
|
61
|
+
}
|
|
62
|
+
async boot() {
|
|
63
|
+
const launch = this.launch();
|
|
64
|
+
if (launch.cwd) await mkdir(launch.cwd, { recursive: true, mode: 0o700 });
|
|
65
|
+
const child = spawn(launch.command, launch.args, { cwd: launch.cwd, env: launch.env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
66
|
+
this.child = child;
|
|
67
|
+
this.generation++;
|
|
68
|
+
this.retired.clear();
|
|
69
|
+
this.queues.clear();
|
|
70
|
+
this.stderrBytes = 0;
|
|
71
|
+
const lines = createInterface({ input: child.stdout });
|
|
72
|
+
lines.on('line', line => {
|
|
73
|
+
if (this.child !== child) return;
|
|
74
|
+
try { this.receive(JSON.parse(line)); }
|
|
75
|
+
catch { this.fail(child, new CodexError('Codex sent malformed protocol data. Retry to start a fresh process.', 'PROTOCOL_ERROR')); }
|
|
76
|
+
});
|
|
77
|
+
// Native stderr can contain request data or credentials. Never relay it to DSH logs.
|
|
78
|
+
child.stderr.on('data', chunk => { this.stderrBytes += chunk.length; });
|
|
79
|
+
child.on('error', () => this.fail(child, new CodexError('Could not start the managed Codex executable.', 'CODEX_START_FAILED')));
|
|
80
|
+
child.stdin.on('error', () => this.fail(child, new CodexError('Codex input pipe closed.', 'CODEX_DISCONNECTED')));
|
|
81
|
+
child.on('exit', (code, signal) => this.fail(child, new CodexError(`Codex exited (${signal ?? code ?? 'unknown'}). Retry to reconnect.`, 'CODEX_DISCONNECTED')));
|
|
82
|
+
try {
|
|
83
|
+
await this.rpc('initialize', { clientInfo: { name: 'dsh_connect', title: 'DSH Connect', version: VERSION }, capabilities: { experimentalApi: true } });
|
|
84
|
+
this.send({ method: 'initialized', params: {} });
|
|
85
|
+
this.lastFailure = undefined;
|
|
86
|
+
} catch (error) { this.fail(child, error); throw error; }
|
|
87
|
+
}
|
|
88
|
+
fail(child, error) {
|
|
89
|
+
if (this.child !== child) return;
|
|
90
|
+
this.child = undefined;
|
|
91
|
+
this.starting = undefined;
|
|
92
|
+
this.lastFailure = error.message;
|
|
93
|
+
for (const pending of [...this.pending.values()]) pending.reject(error);
|
|
94
|
+
for (const queue of this.queues.values()) queue.fail(error);
|
|
95
|
+
child.kill();
|
|
96
|
+
const timer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 2000);
|
|
97
|
+
timer.unref();
|
|
98
|
+
}
|
|
99
|
+
send(message) {
|
|
100
|
+
if (!this.child || !this.child.stdin.writable) throw new CodexError('Codex is disconnected.', 'CODEX_DISCONNECTED');
|
|
101
|
+
this.child.stdin.write(JSON.stringify(message) + '\n');
|
|
102
|
+
}
|
|
103
|
+
rpc(method, params, signal, timeoutMs = this.requestTimeoutMs) {
|
|
104
|
+
signal?.throwIfAborted();
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const id = this.nextId++;
|
|
107
|
+
const finish = (fn, value) => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
signal?.removeEventListener('abort', abort);
|
|
110
|
+
this.pending.delete(id);
|
|
111
|
+
fn(value);
|
|
112
|
+
};
|
|
113
|
+
const abort = () => finish(reject, signal.reason);
|
|
114
|
+
const timer = setTimeout(() => finish(reject, new CodexError(`Codex ${method} timed out.`, 'CODEX_TIMEOUT')), timeoutMs);
|
|
115
|
+
this.pending.set(id, { resolve: value => finish(resolve, value), reject: error => finish(reject, error) });
|
|
116
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
117
|
+
try { this.send({ id, method, params }); }
|
|
118
|
+
catch (error) { finish(reject, error); }
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
async request(method, params = {}, signal, timeoutMs) {
|
|
122
|
+
await this.start(signal);
|
|
123
|
+
return this.rpc(method, params, signal, timeoutMs);
|
|
124
|
+
}
|
|
125
|
+
receive(message) {
|
|
126
|
+
if (!message || typeof message !== 'object') throw new Error('Invalid message');
|
|
127
|
+
if (!message.method && message.id !== undefined) {
|
|
128
|
+
const pending = this.pending.get(message.id);
|
|
129
|
+
if (message.error) pending?.reject(new CodexError(message.error.message ?? 'Codex rejected the request.', 'CODEX_RPC_ERROR'));
|
|
130
|
+
else pending?.resolve(message.result);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const { method, params = {}, id } = message;
|
|
134
|
+
if (typeof method !== 'string') return;
|
|
135
|
+
if (id !== undefined && method !== 'item/tool/call') {
|
|
136
|
+
this.send({ id, error: { code: -32601, message: 'This provider supports Harness dynamic tools only.' } });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const threadId = params.threadId;
|
|
140
|
+
if (typeof threadId !== 'string') return;
|
|
141
|
+
if (this.retired.has(threadId)) {
|
|
142
|
+
if (id !== undefined) this.respond(id, { contentItems: [{ type: 'inputText', text: 'Turn was cancelled.' }], success: false });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.queue(threadId).push({ method, params, requestId: id });
|
|
146
|
+
}
|
|
147
|
+
queue(id) {
|
|
148
|
+
if (!this.queues.has(id)) this.queues.set(id, new EventQueue());
|
|
149
|
+
return this.queues.get(id);
|
|
150
|
+
}
|
|
151
|
+
nextEvent(id, signal) { return this.queue(id).take(signal); }
|
|
152
|
+
respond(id, result) { this.send({ id, result }); }
|
|
153
|
+
async account(signal) { return (await this.request('account/read', { refreshToken: false }, signal)).account; }
|
|
154
|
+
async models(signal) {
|
|
155
|
+
const models = [];
|
|
156
|
+
let cursor;
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
do {
|
|
159
|
+
const result = await this.request('model/list', { limit: 100, ...(cursor ? { cursor } : {}) }, signal);
|
|
160
|
+
models.push(...(result.data ?? []));
|
|
161
|
+
cursor = result.nextCursor;
|
|
162
|
+
if (cursor && seen.has(cursor)) throw new CodexError('Codex model pagination repeated a cursor.', 'PROTOCOL_ERROR');
|
|
163
|
+
seen.add(cursor);
|
|
164
|
+
} while (cursor);
|
|
165
|
+
return models;
|
|
166
|
+
}
|
|
167
|
+
async startThread(params, signal) {
|
|
168
|
+
const result = await this.request('thread/start', params, signal);
|
|
169
|
+
if (typeof result.thread?.id !== 'string') throw new CodexError('Codex returned no thread ID.', 'PROTOCOL_ERROR');
|
|
170
|
+
this.queue(result.thread.id);
|
|
171
|
+
return result.thread.id;
|
|
172
|
+
}
|
|
173
|
+
async startTurn(threadId, params, signal) {
|
|
174
|
+
const result = await this.request('turn/start', { threadId, ...params }, signal);
|
|
175
|
+
if (typeof result.turn?.id !== 'string') throw new CodexError('Codex returned no turn ID.', 'PROTOCOL_ERROR');
|
|
176
|
+
return result.turn.id;
|
|
177
|
+
}
|
|
178
|
+
interrupt(threadId, turnId) { return this.request('turn/interrupt', { threadId, turnId }, undefined, 5000); }
|
|
179
|
+
async release(threadId, turnId) {
|
|
180
|
+
this.retired.add(threadId);
|
|
181
|
+
if (this.retired.size > 1024) this.retired.delete(this.retired.values().next().value);
|
|
182
|
+
this.queues.get(threadId)?.fail(new CodexError('Thread was released.', 'ABORTED'));
|
|
183
|
+
this.queues.delete(threadId);
|
|
184
|
+
if (!this.child) return;
|
|
185
|
+
if (turnId) await this.interrupt(threadId, turnId).catch(() => {});
|
|
186
|
+
await this.request('thread/unsubscribe', { threadId }, undefined, 5000).catch(() => {});
|
|
187
|
+
}
|
|
188
|
+
close() {
|
|
189
|
+
if (this.child) this.fail(this.child, new CodexError('Codex provider stopped.', 'CODEX_DISCONNECTED'));
|
|
190
|
+
}
|
|
191
|
+
}
|