dsh-acp-replay 0.1.1 → 0.1.3
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/README.md +63 -11
- package/cordis.patch.yml +15 -0
- package/package.json +3 -2
- package/test/pin-check.mjs +86 -0
- package/test/probe.mjs +123 -0
- package/test/probe.test.mjs +46 -0
- package/test/selfcheck.mjs +16 -64
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
A community ACP bridge for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) that answers `session/load`, so a client can rebuild a session transcript after its own restart.
|
|
4
4
|
|
|
5
|
-
**Status: prototype.** It works end to end on `dsh 0.1.5-rc.1
|
|
5
|
+
**Status: prototype.** It works end to end on `dsh 0.1.5-rc.1`, `0.1.5-rc.3`, and `0.1.7-rc.2`, but it is a vendored fork of `@deepseek-ai/dsh-acp` and needs a rebase whenever that package changes. No harness version probed here advertises `loadSession` itself, so the bridge is still the only path to a rebuilt transcript; a field report of `0.1.6-alpha.1` answering nothing did not reproduce on the `0.1.7` release candidate.
|
|
6
6
|
|
|
7
|
-
Published on npm as `dsh-acp-replay`. Its version is independent of the harness version it targets, which `peerDependencies` states. `0.1.
|
|
7
|
+
Published on npm as `dsh-acp-replay`. Its version is independent of the harness version it targets, which `peerDependencies` states. `0.1.2` ships `test/*.mjs` and exposes the self-check and the pin check as bins, so both run from an installed copy. (`0.1.5-rc.1` was published with an incomplete file list, so it fails to load; it is deprecated.)
|
|
8
8
|
|
|
9
9
|
## Why this exists
|
|
10
10
|
|
|
@@ -42,23 +42,63 @@ Use the shipped bridge instead ("dsh --profile acp"), or update this plugin for
|
|
|
42
42
|
https://github.com/zheng1/dsh-acp-replay
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Run the self-check after a harness upgrade to get that answer without a full client. From a checkout that is `node test/selfcheck.mjs`; from an installed copy it is `npx dsh-acp-replay-selfcheck`, and a global install puts the same command on `PATH`.
|
|
45
|
+
Run the self-check after a harness upgrade to get that answer without a full client. From a checkout that is `node test/selfcheck.mjs`; from an installed copy it is `npx dsh-acp-replay-selfcheck`, and a global install puts the same command on `PATH`. `dsh-acp-replay-pincheck` runs both profiles and prints which of the upgrade outcomes below applies.
|
|
46
|
+
|
|
47
|
+
The self-check reports "no ACP initialize response" when the harness never answers, which is also what a failed mount looks like. `dsh` rewrites the profile's `cordis.yml` while preparing it, so a sandbox that denies writes to `DSH_HOME` exits on EPERM before answering; the check says so when it sees `EPERM` or `prepareProfile` in the captured stderr, and prints that stderr otherwise.
|
|
48
|
+
|
|
49
|
+
## Upgrading the harness pin
|
|
50
|
+
|
|
51
|
+
The pin is the harness version whose `dsh-acp` this fork was rebased against. Check the candidate before moving it:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
$ DSH_BIN=~/.local/bin/dsh npx dsh-acp-replay-pincheck
|
|
55
|
+
harness: /Users/you/.local/bin/dsh (0.1.5-rc.3)
|
|
56
|
+
shipped profile "acp" does not advertise loadSession
|
|
57
|
+
bridge profile "acp-replay" advertises loadSession (image: yes)
|
|
58
|
+
|
|
59
|
+
PIN: bridge — upgrade the harness and move the pin; this bridge answers session/load on it
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Outcome | Meaning | What to do |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| `upstream` | The shipped profile advertises `loadSession` too | Drop the bridge and unpin; point the client back at `dsh --profile acp` |
|
|
65
|
+
| `bridge` | Only the bridged profile advertises it | Upgrade the harness, then move the pin |
|
|
66
|
+
| `refuses` | Neither profile advertises it | Stay on the pinned harness |
|
|
67
|
+
|
|
68
|
+
`test/replay-check.mjs` is the other half of that check: it records a session and replays it in a new process, so use a session with real content. A 376-byte session legitimately replays nothing, and zero counts then look like a broken bridge.
|
|
46
69
|
|
|
47
70
|
## Use it
|
|
48
71
|
|
|
49
|
-
The bridge replaces the shipped `acp` row in a profile.
|
|
72
|
+
The bridge replaces the shipped `acp` row in a profile. Either way you install it, the bridge row needs a `provider` and a `model`.
|
|
73
|
+
|
|
74
|
+
**From npm.** The package declares `dsh.bundle.patch`, so installing it composes the rows: the shipped `acp` row is disabled and the bridge is inserted.
|
|
50
75
|
|
|
51
|
-
|
|
76
|
+
```bash
|
|
77
|
+
dsh plugin --profile acp-replay add dsh-acp-replay
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then set the model on the inserted row, because the bundle carries none (the model id changes between harness releases) — that is an id-targeted override in the profile's own patch layer, not a second `insert`:
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
# $DSH_HOME/profiles/acp-replay/cordis.patch.yml
|
|
84
|
+
- id: acp-replay
|
|
85
|
+
config:
|
|
86
|
+
provider: deepseek-official
|
|
87
|
+
model: deepseek-v4-flash
|
|
88
|
+
```
|
|
52
89
|
|
|
53
|
-
|
|
90
|
+
Read the values off the shipped row rather than guessing, since the id differs per release (`deepseek-flash` on 0.1.5, `deepseek-v4-flash` on 0.1.7):
|
|
54
91
|
|
|
55
92
|
```bash
|
|
56
|
-
dsh
|
|
93
|
+
DSH_HOME=~/.dsh dsh --profile acp --dump-config | sed -n '/- id: acp$/,/^$/p'
|
|
57
94
|
```
|
|
58
95
|
|
|
59
|
-
|
|
96
|
+
Skip that step and prompts answer with `Internal error: prompt variable "{{model}}" has no value for this assembly`. Add a second `insert` entry for `acp-replay` while the bundle is active and the mount fails with `duplicate loader entry id: acp-replay`.
|
|
97
|
+
|
|
98
|
+
**From a checkout (no bundle).** Install globally (`npm install -g dsh-acp-replay`) or copy `lib/`, `package.json`, and `cordis.patch.yml` into `$DSH_HOME/profiles/node_modules/dsh-acp-replay/`, then spell out both rows:
|
|
60
99
|
|
|
61
100
|
```yaml
|
|
101
|
+
# $DSH_HOME/profiles/acp-replay/cordis.patch.yml
|
|
62
102
|
- id: acp
|
|
63
103
|
disabled: true
|
|
64
104
|
|
|
@@ -68,7 +108,7 @@ The same package also works installed globally (`npm install -g dsh-acp-replay`)
|
|
|
68
108
|
inject: [acpAppStartup]
|
|
69
109
|
config:
|
|
70
110
|
provider: deepseek-official
|
|
71
|
-
model: deepseek-flash
|
|
111
|
+
model: deepseek-v4-flash
|
|
72
112
|
```
|
|
73
113
|
|
|
74
114
|
Then point the client at `dsh --profile acp-replay` instead of `dsh --profile acp`. In Paseo that is the provider's `command`:
|
|
@@ -87,7 +127,7 @@ Then point the client at `dsh --profile acp-replay` instead of `dsh --profile ac
|
|
|
87
127
|
}
|
|
88
128
|
```
|
|
89
129
|
|
|
90
|
-
|
|
130
|
+
The rows the bundle composes are the same two above, so a profile can point at either path without changing the client config.
|
|
91
131
|
|
|
92
132
|
## Build
|
|
93
133
|
|
|
@@ -122,6 +162,16 @@ Against `dsh 0.1.5-rc.1`, a scratch `DSH_HOME`, and a real API key:
|
|
|
122
162
|
|
|
123
163
|
`test/replay-check.mjs` runs both phases (`record` then `load <session-id>`); `test/selfcheck.mjs` only performs `initialize` and reports whether `loadSession` is advertised.
|
|
124
164
|
|
|
165
|
+
**Harness versions.** Each candidate installed with `dsh plugin --profile acp-replay add dsh-acp-replay` into a profile created by that same harness:
|
|
166
|
+
|
|
167
|
+
| Harness | Shipped `acp` profile | Bridged profile | Note |
|
|
168
|
+
| --- | --- | --- | --- |
|
|
169
|
+
| `0.1.5-rc.1` | answers, no `loadSession` | `loadSession: true` | original target; replay verified |
|
|
170
|
+
| `0.1.5-rc.3` (npm `latest`) | answers, no `loadSession` | `loadSession: true` | guard passes, so the pin can move |
|
|
171
|
+
| `0.1.7-rc.2` (npm `next`) | answers, no `loadSession` | `loadSession: true` | record + replay verified |
|
|
172
|
+
|
|
173
|
+
The shipped profile answering every time is the part a bare `initialize` probe can misread: no harness version probed here advertises `loadSession` itself, so the bridge stays necessary.
|
|
174
|
+
|
|
125
175
|
**End to end through a client** (Paseo 0.7.2 daemon, `dsh 0.1.5-rc.1`, two DSH providers on one daemon so only the bridge differs). Each agent ran one prompt, then the daemon was restarted — the event that used to erase the visible conversation:
|
|
126
176
|
|
|
127
177
|
| Provider | Before restart | After restart |
|
|
@@ -150,10 +200,12 @@ $ DSH_PROFILE=acp dsh-acp-replay-selfcheck
|
|
|
150
200
|
FAIL: the bridge answered but does not advertise loadSession # exit 1
|
|
151
201
|
```
|
|
152
202
|
|
|
203
|
+
**`0.1.1` changed no served code.** A downstream field check diffed the published `0.1.1` tarball against the installed `0.1.0`: `lib/` and `cordis.patch.yml` byte-identical, `package.json` differing only in the version, the `bin` entry, and `files`. After the upgrade the same 1.2 MB session replayed to identical counts (`user_message_chunk` 17, `agent_message_chunk` 90, `agent_thought_chunk` 103, `tool_call` 114, `tool_call_update` 114, `usage_update` 113) and the provider diagnostic stayed Ready.
|
|
204
|
+
|
|
153
205
|
## Not verified / known limits
|
|
154
206
|
|
|
155
207
|
- Tool-call replay is mapped but not exercised by the test above.
|
|
156
|
-
- The `0.1.6-alpha.1` no-response finding
|
|
208
|
+
- The `0.1.6-alpha.1` no-response finding was a field report and does not generalize: `0.1.5-rc.3` and `0.1.7-rc.2` both answer and both mount this bridge. No alpha harness has been probed here.
|
|
157
209
|
- An older host (`dsh 0.1.2-rc.1`) exposes a different persistence API (`load`/`inspect` instead of `open`/`read`), so this fork targets `0.1.5-rc.1` and newer only.
|
|
158
210
|
- Replayed history is delivered before `session/load` returns, one notification at a time; a very long session makes that call proportionally long.
|
|
159
211
|
- The profile's own help text still reads `--profile acp` (it comes from the bundled `dsh-acp-app` command provider, not from this package).
|
package/cordis.patch.yml
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Replace the shipped automation bridge with this one, which also answers
|
|
2
2
|
# `session/load` so a client can rebuild a transcript after its own restart.
|
|
3
|
+
#
|
|
4
|
+
# This row carries no `provider`/`model`: the shipped `acp` row sets them, and its
|
|
5
|
+
# model id changes between releases (`deepseek-flash` on 0.1.5, `deepseek-v4-flash`
|
|
6
|
+
# on 0.1.7). Copy that config into the profile's own patch layer, as an id-targeted
|
|
7
|
+
# override — a second `insert` for this id fails the mount:
|
|
8
|
+
#
|
|
9
|
+
# DSH_HOME=~/.dsh dsh --profile acp --dump-config | sed -n '/- id: acp$/,/^$/p'
|
|
10
|
+
#
|
|
11
|
+
# # $DSH_HOME/profiles/<profile>/cordis.patch.yml
|
|
12
|
+
# - id: acp-replay
|
|
13
|
+
# config:
|
|
14
|
+
# provider: deepseek-official
|
|
15
|
+
# model: deepseek-v4-flash
|
|
16
|
+
#
|
|
17
|
+
# Without it, prompts fail with `prompt variable "{{model}}" has no value`.
|
|
3
18
|
|
|
4
19
|
- id: acp
|
|
5
20
|
disabled: true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-acp-replay",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Community ACP bridge for DeepSeek Harness that supports session/load by replaying a persisted session's transcript",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"./package.json": "./package.json"
|
|
15
15
|
},
|
|
16
16
|
"bin": {
|
|
17
|
-
"dsh-acp-replay-selfcheck": "./test/selfcheck.mjs"
|
|
17
|
+
"dsh-acp-replay-selfcheck": "./test/selfcheck.mjs",
|
|
18
|
+
"dsh-acp-replay-pincheck": "./test/pin-check.mjs"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"lib/**/*.js",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Pin check: probe a candidate harness on the shipped profile and on the bridged
|
|
4
|
+
* one, and print which of the three outcomes applies, so moving the harness pin
|
|
5
|
+
* after an upgrade is one command instead of a judgement call.
|
|
6
|
+
*
|
|
7
|
+
* upstream the shipped profile advertises loadSession itself — drop the bridge
|
|
8
|
+
* and unpin: point the client back at `dsh --profile acp`
|
|
9
|
+
* bridge only the bridged profile advertises it — upgrade the harness and
|
|
10
|
+
* move the pin
|
|
11
|
+
* refuses neither profile answers with loadSession — stay on the pinned harness
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* DSH_BIN=~/.local/bin/dsh dsh-acp-replay-pincheck
|
|
15
|
+
*
|
|
16
|
+
* Env: DSH_BIN (default: dsh), DSH_SHIPPED_PROFILE (acp), DSH_PROFILE (acp-replay),
|
|
17
|
+
* PIN_CHECK_TIMEOUT_MS (60000).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process'
|
|
21
|
+
|
|
22
|
+
import { OUTCOMES, describeImage, explainMissingResponse, probeProfile } from './probe.mjs'
|
|
23
|
+
|
|
24
|
+
const bin = process.env.DSH_BIN ?? 'dsh'
|
|
25
|
+
const shippedProfile = process.env.DSH_SHIPPED_PROFILE ?? 'acp'
|
|
26
|
+
const bridgeProfile = process.env.DSH_PROFILE ?? 'acp-replay'
|
|
27
|
+
const timeoutMs = Number(process.env.PIN_CHECK_TIMEOUT_MS ?? 60000)
|
|
28
|
+
|
|
29
|
+
function harnessVersion() {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
32
|
+
let stdout = ''
|
|
33
|
+
child.stdout.on('data', (chunk) => {
|
|
34
|
+
stdout += chunk.toString()
|
|
35
|
+
})
|
|
36
|
+
child.on('error', () => resolve(null))
|
|
37
|
+
child.on('exit', () => resolve(stdout.split('\n')[0].trim() || null))
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function report(label, profile, result) {
|
|
42
|
+
const answered =
|
|
43
|
+
result.kind === OUTCOMES.loadSession
|
|
44
|
+
? `advertises loadSession (${describeImage(result)})`
|
|
45
|
+
: result.message
|
|
46
|
+
return `${`${label} profile "${profile}"`.padEnd(30)} ${answered}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [version, shipped, bridged] = await Promise.all([
|
|
50
|
+
harnessVersion(),
|
|
51
|
+
probeProfile({ bin, profile: shippedProfile, timeoutMs }),
|
|
52
|
+
probeProfile({ bin, profile: bridgeProfile, timeoutMs }),
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
console.log(`harness: ${bin}${version ? ` (${version})` : ''}`)
|
|
56
|
+
console.log(report('shipped', shippedProfile, shipped))
|
|
57
|
+
console.log(report('bridge', bridgeProfile, bridged))
|
|
58
|
+
|
|
59
|
+
let outcome
|
|
60
|
+
let advice
|
|
61
|
+
if (shipped.kind === OUTCOMES.loadSession) {
|
|
62
|
+
outcome = 'upstream'
|
|
63
|
+
advice = `drop the bridge and unpin; point the client at \`${bin} --profile ${shippedProfile}\``
|
|
64
|
+
} else if (bridged.kind === OUTCOMES.loadSession) {
|
|
65
|
+
outcome = 'bridge'
|
|
66
|
+
advice = 'upgrade the harness and move the pin; this bridge answers session/load on it'
|
|
67
|
+
} else {
|
|
68
|
+
outcome = 'refuses'
|
|
69
|
+
advice = 'stay on the pinned harness; neither profile advertises session/load'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(`\nPIN: ${outcome} — ${advice}`)
|
|
73
|
+
|
|
74
|
+
for (const result of [shipped, bridged]) {
|
|
75
|
+
const hint = explainMissingResponse(result)
|
|
76
|
+
if (hint) console.log(`\n${hint}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const answered = new Set([OUTCOMES.loadSession, OUTCOMES.noLoadSession])
|
|
80
|
+
for (const [profile, result] of [
|
|
81
|
+
[shippedProfile, shipped],
|
|
82
|
+
[bridgeProfile, bridged],
|
|
83
|
+
]) {
|
|
84
|
+
if (answered.has(result.kind) || result.stderr.length === 0) continue
|
|
85
|
+
console.log(`\n--- ${bin} --profile ${profile} stderr ---\n${result.stderr}`)
|
|
86
|
+
}
|
package/test/probe.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared ACP probe for the checks in this directory.
|
|
3
|
+
*
|
|
4
|
+
* Boots one profile, performs a single `initialize`, and classifies the answer.
|
|
5
|
+
* Both `selfcheck.mjs` (one profile) and `pin-check.mjs` (shipped profile plus the
|
|
6
|
+
* bridged one) are built on this, so the two report the same thing about the same
|
|
7
|
+
* harness.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
|
|
12
|
+
export const OUTCOMES = {
|
|
13
|
+
loadSession: 'load-session',
|
|
14
|
+
noLoadSession: 'no-load-session',
|
|
15
|
+
noResponse: 'no-response',
|
|
16
|
+
initializeFailed: 'initialize-failed',
|
|
17
|
+
spawnFailed: 'spawn-failed',
|
|
18
|
+
exited: 'exited',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @returns {Promise<{kind: string, message: string, image: boolean | null, stderr: string}>}
|
|
23
|
+
*/
|
|
24
|
+
export async function probeProfile({ bin, profile, timeoutMs = 60000 }) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const child = spawn(bin, ['--profile', profile], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
27
|
+
|
|
28
|
+
let stdout = ''
|
|
29
|
+
let stderr = ''
|
|
30
|
+
let settled = false
|
|
31
|
+
|
|
32
|
+
function finish(result) {
|
|
33
|
+
if (settled) return
|
|
34
|
+
settled = true
|
|
35
|
+
clearTimeout(timer)
|
|
36
|
+
child.kill('SIGTERM')
|
|
37
|
+
resolve({ image: null, ...result, stderr: stderr.trim() })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const timer = setTimeout(
|
|
41
|
+
() =>
|
|
42
|
+
finish({
|
|
43
|
+
kind: OUTCOMES.noResponse,
|
|
44
|
+
message: `no ACP initialize response within ${timeoutMs}ms`,
|
|
45
|
+
}),
|
|
46
|
+
timeoutMs,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
child.on('error', (error) =>
|
|
50
|
+
finish({ kind: OUTCOMES.spawnFailed, message: `could not start ${bin}: ${error.message}` }),
|
|
51
|
+
)
|
|
52
|
+
child.on('exit', (code) =>
|
|
53
|
+
finish({
|
|
54
|
+
kind: OUTCOMES.exited,
|
|
55
|
+
message: `${bin} exited early with code ${code} before answering`,
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
child.stderr.on('data', (chunk) => {
|
|
60
|
+
stderr += chunk.toString()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
child.stdout.on('data', (chunk) => {
|
|
64
|
+
stdout += chunk.toString()
|
|
65
|
+
let index
|
|
66
|
+
while ((index = stdout.indexOf('\n')) >= 0) {
|
|
67
|
+
const line = stdout.slice(0, index).trim()
|
|
68
|
+
stdout = stdout.slice(index + 1)
|
|
69
|
+
if (line.length === 0) continue
|
|
70
|
+
let message
|
|
71
|
+
try {
|
|
72
|
+
message = JSON.parse(line)
|
|
73
|
+
} catch {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (message.id !== 1) continue
|
|
77
|
+
if (message.error) {
|
|
78
|
+
finish({
|
|
79
|
+
kind: OUTCOMES.initializeFailed,
|
|
80
|
+
message: `initialize failed: ${JSON.stringify(message.error).slice(0, 200)}`,
|
|
81
|
+
})
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const capabilities = message.result?.agentCapabilities
|
|
85
|
+
finish({
|
|
86
|
+
kind:
|
|
87
|
+
capabilities?.loadSession === true ? OUTCOMES.loadSession : OUTCOMES.noLoadSession,
|
|
88
|
+
message:
|
|
89
|
+
capabilities?.loadSession === true
|
|
90
|
+
? 'advertises loadSession'
|
|
91
|
+
: 'the bridge answered but does not advertise loadSession',
|
|
92
|
+
image: capabilities?.promptCapabilities?.image === true,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
child.stdin.write(
|
|
98
|
+
`${JSON.stringify({
|
|
99
|
+
jsonrpc: '2.0',
|
|
100
|
+
id: 1,
|
|
101
|
+
method: 'initialize',
|
|
102
|
+
params: { protocolVersion: 1, clientCapabilities: {} },
|
|
103
|
+
})}\n`,
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function describeImage(result) {
|
|
109
|
+
return result.image === true ? 'image: yes' : 'image: no'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `dsh` rewrites the profile's `cordis.yml` while preparing it, so a sandbox that
|
|
114
|
+
* denies writes to `DSH_HOME` exits on EPERM before answering — which the probe can
|
|
115
|
+
* only report as "no response", the same text a failed mount produces.
|
|
116
|
+
*/
|
|
117
|
+
export function explainMissingResponse(result) {
|
|
118
|
+
if (result.kind === OUTCOMES.loadSession || result.kind === OUTCOMES.noLoadSession) return null
|
|
119
|
+
if (/EPERM|prepareProfile|cordis\.yml/i.test(result.stderr)) {
|
|
120
|
+
return 'the harness could not write its profile: run this where DSH_HOME is writable (look for EPERM or prepareProfile in its stderr below)'
|
|
121
|
+
}
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { OUTCOMES, describeImage, explainMissingResponse } from './probe.mjs'
|
|
5
|
+
|
|
6
|
+
test('reports loadSession and the image capability', () => {
|
|
7
|
+
assert.equal(describeImage({ image: true }), 'image: yes')
|
|
8
|
+
assert.equal(describeImage({ image: false }), 'image: no')
|
|
9
|
+
assert.equal(describeImage({ image: null }), 'image: no')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('explains a silent probe whose harness could not write its profile', () => {
|
|
13
|
+
const hint = explainMissingResponse({
|
|
14
|
+
kind: OUTCOMES.noResponse,
|
|
15
|
+
stderr: 'Error: EPERM: operation not permitted, open cordis.yml',
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
assert.match(hint ?? '', /DSH_HOME is writable/)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('explains a probe that failed while preparing the profile', () => {
|
|
22
|
+
const hint = explainMissingResponse({
|
|
23
|
+
kind: OUTCOMES.exited,
|
|
24
|
+
stderr: 'prepareProfile failed: read-only file system',
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
assert.match(hint ?? '', /DSH_HOME is writable/)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('leaves an answered probe alone', () => {
|
|
31
|
+
assert.equal(
|
|
32
|
+
explainMissingResponse({ kind: OUTCOMES.loadSession, stderr: 'EPERM cordis.yml' }),
|
|
33
|
+
null,
|
|
34
|
+
)
|
|
35
|
+
assert.equal(
|
|
36
|
+
explainMissingResponse({ kind: OUTCOMES.noLoadSession, stderr: 'prepareProfile' }),
|
|
37
|
+
null,
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('does not guess at an unrelated failure', () => {
|
|
42
|
+
assert.equal(
|
|
43
|
+
explainMissingResponse({ kind: OUTCOMES.noResponse, stderr: 'some other error' }),
|
|
44
|
+
null,
|
|
45
|
+
)
|
|
46
|
+
})
|
package/test/selfcheck.mjs
CHANGED
|
@@ -1,85 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Self-check for the dsh-acp-replay
|
|
3
|
+
* Self-check for the dsh-acp-replay bridge.
|
|
4
4
|
*
|
|
5
|
-
* Boots
|
|
5
|
+
* Boots one profile, performs one ACP `initialize`, and reports whether the
|
|
6
6
|
* bridge answered and advertised `loadSession`. Run it after any harness
|
|
7
7
|
* upgrade: if the vendored bridge no longer matches the installed DSH, the
|
|
8
8
|
* mount-time guard prints its reason to stderr and this script exits non-zero
|
|
9
9
|
* with that text, instead of leaving "the provider will not start" unexplained.
|
|
10
10
|
*
|
|
11
11
|
* Usage:
|
|
12
|
-
* DSH_BIN=dsh DSH_PROFILE=acp-replay
|
|
12
|
+
* DSH_BIN=dsh DSH_PROFILE=acp-replay dsh-acp-replay-selfcheck
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { OUTCOMES, describeImage, explainMissingResponse, probeProfile } from './probe.mjs'
|
|
16
16
|
|
|
17
17
|
const bin = process.env.DSH_BIN ?? 'dsh'
|
|
18
18
|
const profile = process.env.DSH_PROFILE ?? 'acp-replay'
|
|
19
19
|
const timeoutMs = Number(process.env.SELFCHECK_TIMEOUT_MS ?? 60000)
|
|
20
20
|
|
|
21
|
-
const
|
|
21
|
+
const result = await probeProfile({ bin, profile, timeoutMs })
|
|
22
|
+
const ok = result.kind === OUTCOMES.loadSession
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
function finish(ok, message) {
|
|
28
|
-
if (settled) return
|
|
29
|
-
settled = true
|
|
30
|
-
clearTimeout(timer)
|
|
31
|
-
child.kill('SIGTERM')
|
|
32
|
-
const details = stderr.trim()
|
|
33
|
-
console.log(`${ok ? 'OK' : 'FAIL'}: ${message}`)
|
|
34
|
-
if (!ok && details.length > 0) console.log(`\n--- ${bin} stderr ---\n${details}`)
|
|
35
|
-
process.exit(ok ? 0 : 1)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const timer = setTimeout(
|
|
39
|
-
() => finish(false, `no ACP initialize response within ${timeoutMs}ms`),
|
|
40
|
-
timeoutMs,
|
|
24
|
+
console.log(
|
|
25
|
+
ok
|
|
26
|
+
? `OK: profile "${profile}" ${result.message} (${describeImage(result)})`
|
|
27
|
+
: `FAIL: ${result.message}`,
|
|
41
28
|
)
|
|
42
29
|
|
|
43
|
-
|
|
44
|
-
|
|
30
|
+
const hint = explainMissingResponse(result)
|
|
31
|
+
if (hint) console.log(`\n${hint}`)
|
|
45
32
|
|
|
46
|
-
|
|
47
|
-
stderr
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
child.stdout.on('data', (chunk) => {
|
|
51
|
-
stdout += chunk.toString()
|
|
52
|
-
let index
|
|
53
|
-
while ((index = stdout.indexOf('\n')) >= 0) {
|
|
54
|
-
const line = stdout.slice(0, index).trim()
|
|
55
|
-
stdout = stdout.slice(index + 1)
|
|
56
|
-
if (line.length === 0) continue
|
|
57
|
-
let message
|
|
58
|
-
try {
|
|
59
|
-
message = JSON.parse(line)
|
|
60
|
-
} catch {
|
|
61
|
-
continue
|
|
62
|
-
}
|
|
63
|
-
if (message.id !== 1) continue
|
|
64
|
-
if (message.error) {
|
|
65
|
-
finish(false, `initialize failed: ${JSON.stringify(message.error).slice(0, 200)}`)
|
|
66
|
-
continue
|
|
67
|
-
}
|
|
68
|
-
const capabilities = message.result?.agentCapabilities
|
|
69
|
-
if (capabilities?.loadSession !== true) {
|
|
70
|
-
finish(false, 'the bridge answered but does not advertise loadSession')
|
|
71
|
-
continue
|
|
72
|
-
}
|
|
73
|
-
const image = capabilities?.promptCapabilities?.image === true ? 'image: yes' : 'image: no'
|
|
74
|
-
finish(true, `profile "${profile}" advertises loadSession (${image})`)
|
|
75
|
-
}
|
|
76
|
-
})
|
|
33
|
+
if (!ok && result.stderr.length > 0) {
|
|
34
|
+
console.log(`\n--- ${bin} stderr ---\n${result.stderr}`)
|
|
35
|
+
}
|
|
77
36
|
|
|
78
|
-
|
|
79
|
-
`${JSON.stringify({
|
|
80
|
-
jsonrpc: '2.0',
|
|
81
|
-
id: 1,
|
|
82
|
-
method: 'initialize',
|
|
83
|
-
params: { protocolVersion: 1, clientCapabilities: {} },
|
|
84
|
-
})}\n`,
|
|
85
|
-
)
|
|
37
|
+
process.exit(ok ? 0 : 1)
|