session-peer 0.1.0-preview.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +59 -0
- package/LICENSE +21 -0
- package/README.ja.md +109 -0
- package/README.ko.md +168 -0
- package/README.md +173 -0
- package/README.zh-CN.md +109 -0
- package/RELEASING.md +153 -0
- package/SECURITY.md +19 -0
- package/VALIDATION.md +77 -0
- package/dist/cli.js +190 -0
- package/dist/discovery.js +148 -0
- package/dist/process.js +43 -0
- package/dist/protocol.js +50 -0
- package/dist/send.js +88 -0
- package/dist/writer.js +161 -0
- package/package.json +44 -0
package/RELEASING.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Releasing session-peer previews
|
|
2
|
+
|
|
3
|
+
The package is publication-ready (`private:false`), not automatically published.
|
|
4
|
+
Merging, pushing, tagging or publishing a GitHub release does not run publish.yml.
|
|
5
|
+
Only an explicitly approved manual dispatch can upload. Python session-peer and
|
|
6
|
+
its Relay release train remain independent.
|
|
7
|
+
|
|
8
|
+
## Before the first dispatch
|
|
9
|
+
|
|
10
|
+
1. Merge the reviewed preparation PR and require main's exact-commit `cli` CI
|
|
11
|
+
(`release gate`) to pass. Match versions in package.json, lockfile,
|
|
12
|
+
src/protocol.ts and four READMEs. Only `X.Y.Z-preview.N` versions and the
|
|
13
|
+
`preview` dist-tag are supported; stable publication needs another review.
|
|
14
|
+
2. In GitHub Settings → Environments, create **npm** with at least one required
|
|
15
|
+
reviewer. Restrict deployments to protected main. A solo maintainer may also
|
|
16
|
+
be the dispatcher: this is explicit approval, not two-person separation.
|
|
17
|
+
Do not bypass protection. Preflight rejects missing environments or reviewer
|
|
18
|
+
rules. This PR does not create or relax environment protection.
|
|
19
|
+
3. Confirm npm account 2FA, package-name ownership and creation permissions.
|
|
20
|
+
A registry 404 does not reserve the name. Review VALIDATION.md and architecture
|
|
21
|
+
coverage; historical real-TUI ACKs predate the command rename. Do not claim
|
|
22
|
+
full native-agent architecture coverage from CI fixtures.
|
|
23
|
+
4. For the initial **0.1.0-preview.0 only**, repository secret **NPM_TOKEN** supplies
|
|
24
|
+
bootstrap authentication. Use a short-expiry, least-privilege granular token
|
|
25
|
+
permitting this new package. Noninteractive direct publication requires a token
|
|
26
|
+
allowed to bypass npm's 2FA prompt. Secret existence does not establish its
|
|
27
|
+
permissions. Do not weaken account policy automatically: stop if unsuitable
|
|
28
|
+
and use an approved local interactive first publish instead.
|
|
29
|
+
Never put credentials in files, logs, issues or chat. Builds/tests/packing
|
|
30
|
+
never receive this secret.
|
|
31
|
+
5. Obtain final approval for the exact main commit, version and mode. A merged
|
|
32
|
+
preparation PR is not publication approval. Optional GitHub draft release
|
|
33
|
+
notes must target that same commit; the workflow creates no tag or release.
|
|
34
|
+
Runtime changes require separate reviewed evidence.
|
|
35
|
+
|
|
36
|
+
## First package: bootstrap-token
|
|
37
|
+
|
|
38
|
+
### Initial failed attempt (2026-09-27)
|
|
39
|
+
|
|
40
|
+
[Run 36308424358](https://github.com/abruption/session-peer-ts/actions/runs/36308424358)
|
|
41
|
+
attempted the old npm name `session-peer-ts@0.1.0-preview.0`. Build and artifact
|
|
42
|
+
checks passed, but the registry refused the PUT with HTTP 403 / E_STAGE_REQUIRED:
|
|
43
|
+
the supplied token could only stage, and the package did not exist. A Sigstore
|
|
44
|
+
provenance entry was created before that rejection; it is not proof of npm
|
|
45
|
+
publication. Both npm names returned 404 during the subsequent read-only check.
|
|
46
|
+
|
|
47
|
+
The intended npm name is now **session-peer**; the GitHub repository remains
|
|
48
|
+
**abruption/session-peer-ts**. Renaming does not fix the authentication restriction.
|
|
49
|
+
Do not rerun the old workflow or switch it to staging: a new package cannot be
|
|
50
|
+
created by staging. Before an approved new attempt, the owner must resolve the
|
|
51
|
+
initial direct-publication authorization or perform an approved interactive
|
|
52
|
+
first publish with 2FA. Do not disable 2FA, automatically replace credentials or
|
|
53
|
+
assume changing a token option bypasses current npm policy. The stored secret's
|
|
54
|
+
value and settings were not inspected. This PR does not reattempt publication.
|
|
55
|
+
|
|
56
|
+
### Inputs after authentication is resolved
|
|
57
|
+
|
|
58
|
+
Actions → **npm preview publication** → Run workflow → **main**:
|
|
59
|
+
|
|
60
|
+
- mode: `bootstrap-token`
|
|
61
|
+
- version: `0.1.0-preview.0`
|
|
62
|
+
- confirmation: `session-peer@0.1.0-preview.0 bootstrap-token`
|
|
63
|
+
|
|
64
|
+
Preflight requires current main SHA, successful exact-commit main CI and the
|
|
65
|
+
protected npm environment. Bootstrap refuses **any existing registry package**
|
|
66
|
+
under this name. The prepare job tests without publication credentials, packs
|
|
67
|
+
twice, compares hashes, checks the allowlist and tests installation/uninstallation
|
|
68
|
+
of the exact tarball. Its retained artifact includes tarball and manifest.json
|
|
69
|
+
(commit, SHA-256 and SHA-512 integrity). Actions are pinned by commit; release
|
|
70
|
+
builds use Node 24/npm 11.15.0 without dependency caches or lifecycle scripts.
|
|
71
|
+
|
|
72
|
+
After environment approval, a fresh job downloads the artifact without rebuilding,
|
|
73
|
+
rechecks main/CI/registry and validates its contents. Only one `npm publish`
|
|
74
|
+
step receives NPM_TOKEN (`--tag preview --access public --provenance
|
|
75
|
+
--ignore-scripts`). OIDC permission is also granted for provenance. Upload
|
|
76
|
+
network retries are disabled. Do not rerun failed or uncertain publication.
|
|
77
|
+
|
|
78
|
+
Verification compares registry integrity and preview tag, rejects accidental
|
|
79
|
+
latest promotion, requires registry attestation metadata, runs `npm audit
|
|
80
|
+
signatures`, and fresh-installs/uninstalls the published version. Only a passed
|
|
81
|
+
verification completes bootstrap release; a successful upload alone does not.
|
|
82
|
+
Bounded read-only propagation checks are not upload retries.
|
|
83
|
+
|
|
84
|
+
## Register npm Trusted Publisher after the first release
|
|
85
|
+
|
|
86
|
+
npmjs.com → package **session-peer** → Settings → Trusted publishing:
|
|
87
|
+
|
|
88
|
+
| Field | Value |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| Provider | GitHub Actions |
|
|
91
|
+
| Organization or user | `abruption` |
|
|
92
|
+
| Repository | `session-peer-ts` |
|
|
93
|
+
| Workflow filename | `publish.yml` (no directory prefix) |
|
|
94
|
+
| Environment name | `npm` |
|
|
95
|
+
| Allowed actions | Stage-only: `npm stage publish`, not direct `npm publish` |
|
|
96
|
+
|
|
97
|
+
The workflow must exist on main. npm does not validate the connection on save;
|
|
98
|
+
validate it with the next authorized staged version. GitHub-hosted runners are
|
|
99
|
+
required. Public GitHub + public npm OIDC publishing provides provenance.
|
|
100
|
+
|
|
101
|
+
After verifying bootstrap and configuring trust, revoke its token in npm and
|
|
102
|
+
remove GitHub NPM_TOKEN. Set npm publishing access to **Require two-factor
|
|
103
|
+
authentication and disallow tokens**. These account changes need owner approval
|
|
104
|
+
and are not performed by this workflow. trusted-stage never references NPM_TOKEN;
|
|
105
|
+
an OIDC failure must not cause token fallback. Remove the bootstrap branch in a
|
|
106
|
+
follow-up PR.
|
|
107
|
+
|
|
108
|
+
## Later previews: trusted-stage
|
|
109
|
+
|
|
110
|
+
Prepare a new immutable preview version through a PR. Dispatch main with mode
|
|
111
|
+
`trusted-stage`, its exact version and confirmation
|
|
112
|
+
`session-peer@<version> trusted-stage`. The same gates apply. The package must
|
|
113
|
+
already exist; published versions are refused. One `npm stage publish` runs
|
|
114
|
+
with OIDC and the preview tag.
|
|
115
|
+
|
|
116
|
+
**A successful staged run is pending approval, not a public release.** Record its
|
|
117
|
+
stage ID from npm output. Review its tarball against the retained manifest and
|
|
118
|
+
approve with 2FA in npmjs.com or interactive `npm stage approve`. OIDC cannot
|
|
119
|
+
approve, list or view stages. Public registry metadata cannot reveal pending
|
|
120
|
+
stages, so collisions fail at the registry and need manual inspection, not retries.
|
|
121
|
+
|
|
122
|
+
After approval, check out the exact source commit and download that run artifact
|
|
123
|
+
into `release-dist/`. With Node 24 and npm 11.15.0:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
node scripts/release.mjs artifact
|
|
127
|
+
node scripts/release.mjs verify
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Neither command publishes. verify uses public reads, an isolated no-lifecycle
|
|
131
|
+
install and signature checks; it needs no publish token. Do not announce release
|
|
132
|
+
completion until it passes. Stable promotion and fleet installation remain
|
|
133
|
+
separate decisions.
|
|
134
|
+
|
|
135
|
+
## Failure handling
|
|
136
|
+
|
|
137
|
+
Missing/invalid token, 2FA challenge, missing environment, bad OIDC mapping,
|
|
138
|
+
non-404 registry errors, stale main, conflicting versions, upload timeout and
|
|
139
|
+
hash mismatch are stop conditions. Never automatically change auth mode, retry
|
|
140
|
+
upload, unpublish, move a tag, reuse a version or promote latest to obtain green
|
|
141
|
+
checks. Inspect registry/staged state and retained evidence first. Read-only
|
|
142
|
+
verification of the exact artifact may be repeated; publication may not.
|
|
143
|
+
A broken/partial public release requires a reviewed new version and approval.
|
|
144
|
+
|
|
145
|
+
## Sources
|
|
146
|
+
|
|
147
|
+
- [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/)
|
|
148
|
+
- [npm staged publishing](https://docs.npmjs.com/staged-publishing)
|
|
149
|
+
- [npm stage CLI and token/2FA behavior](https://docs.npmjs.com/cli/v11/commands/npm-stage)
|
|
150
|
+
|
|
151
|
+
Requirements checked 2026-09-27: OIDC needs npm ≥11.5.1, staging ≥11.15.0, both
|
|
152
|
+
with Node ≥22.14.0. Application support remains Node 22.13+ within 22.x and 24.x;
|
|
153
|
+
release-tool requirements are separate.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
Report vulnerabilities through [GitHub private vulnerability reporting](https://github.com/abruption/session-peer-ts/security/advisories/new),
|
|
4
|
+
not public issues, PRs or comments. Alternatively email
|
|
5
|
+
[support@abruption.dev](mailto:support@abruption.dev?subject=%5Bsession-peer-ts%5D%20Security)
|
|
6
|
+
with `[session-peer-ts] Security`. Email is private contact, not an end-to-end
|
|
7
|
+
encrypted submission mechanism. Start with a redacted description only.
|
|
8
|
+
|
|
9
|
+
Include commit/version, install method, OS, architecture, Node and native agent
|
|
10
|
+
versions on each endpoint, affected command, attacker access, impact and a minimal
|
|
11
|
+
reproduction using dummy sessions/paths/messages. Remove credentials, tokens,
|
|
12
|
+
keys, message content, session IDs and personal hosts/paths. Do not attach agent
|
|
13
|
+
homes, SQLite DBs, transcripts, browser profiles or .env files. Rotate exposed
|
|
14
|
+
secrets with their provider; deleting a comment does not revoke a secret.
|
|
15
|
+
|
|
16
|
+
Review is manual and best-effort, with no response or fix deadline. The project is
|
|
17
|
+
an unpublished preview; reports on any commit are welcome but no stable support
|
|
18
|
+
or backport promise exists. English and Korean reports are welcome. Disclosure
|
|
19
|
+
is coordinated with the reporter; private reports are not automatically public.
|
package/VALIDATION.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Validation record — 2026-09-27 KST
|
|
2
|
+
|
|
3
|
+
This is preview evidence, not an npm release or a claim of complete Python parity.
|
|
4
|
+
Python reference: v1.0.2, commit `47c23713d0a2a3c11ebde6186afd8c43489b8b65`.
|
|
5
|
+
|
|
6
|
+
## Automated checks
|
|
7
|
+
|
|
8
|
+
- 14 tests pass locally on macOS arm64 / Node 22.14.0.
|
|
9
|
+
- Real fixture SQLite DBs, Unix inbox sockets and separate C processes holding
|
|
10
|
+
actual advisory locks; Python is the discovery oracle, not the implementation.
|
|
11
|
+
- Refused inactive/ambiguous/wrong-owner cases; one native fixture queue call;
|
|
12
|
+
native nonzero after spawn and SSH response loss remain unknown with no retry.
|
|
13
|
+
- Package file allowlist, same-build repeat SHA-256, clean prefix installation,
|
|
14
|
+
empty-PATH discovery and uninstall pass. npm audit reports 0 vulnerabilities.
|
|
15
|
+
- CI defines macOS/Linux × Node 22/24; see the repository Actions results for
|
|
16
|
+
those runs rather than treating this local check as their substitute.
|
|
17
|
+
|
|
18
|
+
## Real TUI and SSH checks
|
|
19
|
+
|
|
20
|
+
Dedicated, newly created Claude/Codex test TUIs only. The user approved trusting
|
|
21
|
+
the two otherwise empty test folders. Agent permissions were not changed.
|
|
22
|
+
Each route passed discovery and no-delivery dry-run before exactly one send.
|
|
23
|
+
ACKs below were observed as separate **assistant response lines in the owned
|
|
24
|
+
live tmux panes**, not copied from submitted prompts, queues or transcript files.
|
|
25
|
+
|
|
26
|
+
| Route | Native target | Submission | Actual response |
|
|
27
|
+
| --- | --- | --- | --- |
|
|
28
|
+
| Mac mini local | Claude Code 2.1.281 | posted | `ACK TS186-LOCAL-CLAUDE-8fc721d0` |
|
|
29
|
+
| Mac mini local | Codex CLI 0.157.1 | queued | `ACK TS186-LOCAL-CODEX-cb042ac9` |
|
|
30
|
+
| Mac mini → MacBook SSH | Claude Code 2.1.281 | posted | `ACK TS186-SSH-CLAUDE-f0275ade` |
|
|
31
|
+
| Mac mini → MacBook SSH, structured Reply-To URI as destination | Codex CLI 0.157.1 | queued | `ACK TS186-SSH-CODEX-a771e96c` |
|
|
32
|
+
|
|
33
|
+
Local Node: 22.14.0. Remote MacBook: 24.20.0, selected by a task-owned wrapper
|
|
34
|
+
because its normal PATH selected unsupported Node 26. KR Linux Node 24.16.0
|
|
35
|
+
also passed real SSH version negotiation and empty Claude discovery. KR native
|
|
36
|
+
agent delivery was not tested. No service was restarted or global CLI replaced.
|
|
37
|
+
|
|
38
|
+
The CLI itself still reports `consumptionConfirmed:false`; external ACK review
|
|
39
|
+
does not change that contract. This is a one-shot functional test, not a soak.
|
|
40
|
+
Local self-SSH was denied by the existing SSH authentication configuration; no
|
|
41
|
+
credentials or host-key bypass were introduced. Reverse SSH was not asserted.
|
|
42
|
+
|
|
43
|
+
## Historical runtime build checked on both Macs
|
|
44
|
+
|
|
45
|
+
Compiled module SHA-256 values matched between the local build and the isolated
|
|
46
|
+
MacBook installation used for the SSH ACK tests:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
0b708f1211330ff489b6a405e0ebb71d2b3b392acdb19c216dba18e7b23016c7 cli.js
|
|
50
|
+
1d8c41dfc174d950e2196c64f93b324f09befa388a5b303439c390a526ffda48 discovery.js
|
|
51
|
+
c3187c99c46364730e46e35ef23d8f3a37e541593b2a6f9c3945dce979e7e99c process.js
|
|
52
|
+
b716fed8fe6c8c04047f8485a6e3f982d6e64e5638df570b3b8c2a128f88c81e protocol.js
|
|
53
|
+
dd946024a61ce8721c273d47e911494c0c8facd62facd9b791a451b90cb702bf send.js
|
|
54
|
+
c441567e83c64c7d5735708936e9a42b68dd9ea0cdf0ef95538da7919d550496 writer.js
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
These hashes and real-TUI ACKs precede the public command rename to
|
|
58
|
+
`session-peer` (the npm package was then named `session-peer-ts`). The rename changes the
|
|
59
|
+
CLI version banner to `session-peer 0.1.0-preview.0 (typescript)` and the default
|
|
60
|
+
SSH executable to `session-peer`; it does not change native delivery logic.
|
|
61
|
+
Fresh package-install and SSH fixture tests cover the renamed entry point and
|
|
62
|
+
reject a different implementation on remote PATH before sending. The earlier
|
|
63
|
+
ACKs are not represented as a new live test of the renamed build.
|
|
64
|
+
|
|
65
|
+
The npm package name was subsequently changed to `session-peer` before a
|
|
66
|
+
successful publication; the GitHub repository remains `session-peer-ts`.
|
|
67
|
+
Package identity, tarball naming and publication confirmations are covered by
|
|
68
|
+
package/release fixtures, not claimed as new live ACK evidence.
|
|
69
|
+
|
|
70
|
+
## Not covered / not released
|
|
71
|
+
|
|
72
|
+
Windows, native x64 agent interaction, IPv6 literals, Relay/MCP/wake, full Python
|
|
73
|
+
CLI parity, automatic reverse-route detection, npm registry publishing and
|
|
74
|
+
provenance are outside this historical evidence. Publication preparation now
|
|
75
|
+
removes the private flag, but actual publishing still needs separate approval
|
|
76
|
+
and the gates in RELEASING.md. Native dependency prebuild availability must be tested
|
|
77
|
+
on each architecture claimed by a future release.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { claude, codex, Refusal } from './discovery.js';
|
|
4
|
+
import { executable, run, UnknownOutcome } from './process.js';
|
|
5
|
+
import { checkMessage, send } from './send.js';
|
|
6
|
+
import { envelope, host, reply, VERSION, VERSION_LINE } from './protocol.js';
|
|
7
|
+
export function parse(args) {
|
|
8
|
+
const command = args[0];
|
|
9
|
+
if (command !== 'list' && command !== 'send')
|
|
10
|
+
throw new Refusal('unsupported_command');
|
|
11
|
+
const values = new Map(), flags = new Set();
|
|
12
|
+
for (let i = 1; i < args.length; i++) {
|
|
13
|
+
const token = args[i];
|
|
14
|
+
const split = token.indexOf('=');
|
|
15
|
+
const key = split > 0 ? token.slice(0, split) : token;
|
|
16
|
+
if (['--json', '--all', '--dry-run', '--no-from', '--no-reply-to', '--no-update-notice'].includes(key)) {
|
|
17
|
+
if (flags.has(key) || split > 0)
|
|
18
|
+
throw new Refusal('invalid_option');
|
|
19
|
+
flags.add(key);
|
|
20
|
+
}
|
|
21
|
+
else if (['--agent', '--codex-home', '--codex-bin', '--output-format', '--to', '--message', '-m', '--host', '--remote-bin', '--reply-address'].includes(key)) {
|
|
22
|
+
const name = key === '-m' ? '--message' : key;
|
|
23
|
+
const value = split > 0 ? token.slice(split + 1) : args[++i];
|
|
24
|
+
if (value === undefined || values.has(name))
|
|
25
|
+
throw new Refusal('invalid_option');
|
|
26
|
+
values.set(name, value);
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
throw new Refusal('unsupported_option');
|
|
30
|
+
}
|
|
31
|
+
if (!flags.has('--json') && values.get('--output-format') !== 'json')
|
|
32
|
+
throw new Refusal('json_output_required');
|
|
33
|
+
if (values.has('--output-format') && values.get('--output-format') !== 'json')
|
|
34
|
+
throw new Refusal('unsupported_output_format');
|
|
35
|
+
if (command === 'list') {
|
|
36
|
+
if (!['claude', 'codex'].includes(values.get('--agent') ?? ''))
|
|
37
|
+
throw new Refusal('explicit_supported_agent_required');
|
|
38
|
+
if (values.get('--agent') === 'codex' && !values.get('--codex-home'))
|
|
39
|
+
throw new Refusal('explicit_codex_home_required');
|
|
40
|
+
if (values.get('--agent') !== 'codex' && values.has('--codex-home'))
|
|
41
|
+
throw new Refusal('inapplicable_option');
|
|
42
|
+
if (['--to', '--message', '--codex-bin', '--reply-address'].some(k => values.has(k)) || ['--dry-run', '--no-from', '--no-reply-to'].some(k => flags.has(k)))
|
|
43
|
+
throw new Refusal('inapplicable_option');
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
if (!values.get('--to') || values.has('--agent') || flags.has('--all'))
|
|
47
|
+
throw new Refusal('invalid_send_options');
|
|
48
|
+
if (flags.has('--no-reply-to') && values.has('--reply-address'))
|
|
49
|
+
throw new Refusal('conflicting_reply_options');
|
|
50
|
+
if (values.get('--to').startsWith('session-peer:')) {
|
|
51
|
+
const route = reply(values.get('--to'));
|
|
52
|
+
for (const [key, value] of [['--host', route.host], ['--codex-home', route.home]]) {
|
|
53
|
+
if (values.has(key) && values.get(key) !== value)
|
|
54
|
+
throw new Refusal('reply_route_conflict');
|
|
55
|
+
if (value !== undefined)
|
|
56
|
+
values.set(key, value);
|
|
57
|
+
}
|
|
58
|
+
values.set('--to', route.to);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (values.has('--host'))
|
|
62
|
+
host(values.get('--host'));
|
|
63
|
+
if (values.has('--remote-bin')) {
|
|
64
|
+
if (!values.has('--host') || !/^\/[\x20-\x7e]+$/.test(values.get('--remote-bin')))
|
|
65
|
+
throw new Refusal('invalid_remote_bin');
|
|
66
|
+
}
|
|
67
|
+
return { command, values, flags };
|
|
68
|
+
}
|
|
69
|
+
async function input() {
|
|
70
|
+
const chunks = [];
|
|
71
|
+
let bytes = 0;
|
|
72
|
+
for await (const chunk of process.stdin) {
|
|
73
|
+
const part = Buffer.from(chunk);
|
|
74
|
+
bytes += part.length;
|
|
75
|
+
if (bytes > 4_100_000)
|
|
76
|
+
throw new Refusal('input_too_large');
|
|
77
|
+
chunks.push(part);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new Refusal('invalid_utf8');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const quote = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
|
|
87
|
+
async function remote(options, message) {
|
|
88
|
+
const { values, flags, command } = options;
|
|
89
|
+
const target = host(values.get('--host'));
|
|
90
|
+
const binary = values.get('--remote-bin') ?? 'session-peer';
|
|
91
|
+
const ssh = executable('ssh');
|
|
92
|
+
const base = ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10', '--', target];
|
|
93
|
+
const preflight = await run(ssh, [...base, `${quote(binary)} --version`], { timeout: 15000 });
|
|
94
|
+
if (preflight.interrupted || preflight.code !== 0 || preflight.stdout.trim() !== VERSION_LINE)
|
|
95
|
+
throw new Refusal('remote_version_unverified', 1);
|
|
96
|
+
const args = [command, '--json'];
|
|
97
|
+
for (const [key, value] of values)
|
|
98
|
+
if (['--agent', '--codex-home', '--codex-bin', '--to'].includes(key))
|
|
99
|
+
args.push(key, value);
|
|
100
|
+
if (flags.has('--all'))
|
|
101
|
+
args.push('--all');
|
|
102
|
+
if (flags.has('--dry-run'))
|
|
103
|
+
args.push('--dry-run');
|
|
104
|
+
if (command === 'send')
|
|
105
|
+
args.push('--message', message, '--no-from', '--no-reply-to');
|
|
106
|
+
const done = await run(ssh, [...base, `${quote(binary)} --stdio-request`], {
|
|
107
|
+
timeout: 90000, input: JSON.stringify({ schemaVersion: 1, args })
|
|
108
|
+
});
|
|
109
|
+
const uncertain = () => { if (command === 'send' && !flags.has('--dry-run') && done.spawned)
|
|
110
|
+
throw new UnknownOutcome(); throw new Refusal('remote_response_unverified', 1); };
|
|
111
|
+
if (done.interrupted || !done.spawned)
|
|
112
|
+
return uncertain();
|
|
113
|
+
let value;
|
|
114
|
+
try {
|
|
115
|
+
value = JSON.parse(done.stdout);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return uncertain();
|
|
119
|
+
}
|
|
120
|
+
if (!value || value.schemaVersion !== 1 || value.command !== command || typeof value.ok !== 'boolean' || typeof value.host !== 'string' ||
|
|
121
|
+
![0, 1, 2].includes(done.code ?? -1) || (value.ok !== (done.code === 0)))
|
|
122
|
+
return uncertain();
|
|
123
|
+
const expectedStatus = flags.has('--dry-run') ? 'validated' : values.get('--to')?.startsWith('codex:') ? 'queued' : 'posted';
|
|
124
|
+
if (command === 'send' && (value.consumptionConfirmed !== false ||
|
|
125
|
+
(value.ok && (value.submitted !== !flags.has('--dry-run') || value.status !== expectedStatus)) ||
|
|
126
|
+
(!value.ok && !((value.submitted === false && value.status === 'refused') || (value.submitted === null && value.status === 'unknown')))))
|
|
127
|
+
return uncertain();
|
|
128
|
+
return { ...value, host: target, sshHost: target };
|
|
129
|
+
}
|
|
130
|
+
let command = 'unknown';
|
|
131
|
+
try {
|
|
132
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
133
|
+
if (!((major === 22 && minor >= 13) || major === 24))
|
|
134
|
+
throw new Refusal('unsupported_node_version');
|
|
135
|
+
if (!['darwin', 'linux'].includes(process.platform))
|
|
136
|
+
throw new Refusal('unsupported_platform');
|
|
137
|
+
let args = process.argv.slice(2);
|
|
138
|
+
if (args.length === 1 && args[0] === '--version')
|
|
139
|
+
console.log(VERSION_LINE);
|
|
140
|
+
else if (args.length === 1 && ['--help', '-h'].includes(args[0]))
|
|
141
|
+
console.log('session-peer (TypeScript preview): list --agent claude|codex --json; send --to TARGET --message TEXT --json [--dry-run] [--host HOST] [--remote-bin ABSOLUTE_PATH]. Codex requires --codex-home. Relay/MCP/wake/Windows unsupported.');
|
|
142
|
+
else {
|
|
143
|
+
const wire = args.length === 1 && args[0] === '--stdio-request';
|
|
144
|
+
if (wire) {
|
|
145
|
+
let request;
|
|
146
|
+
try {
|
|
147
|
+
request = JSON.parse(await input());
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new Refusal('invalid_remote_request');
|
|
151
|
+
}
|
|
152
|
+
const record = request;
|
|
153
|
+
if (!record || record.schemaVersion !== 1 || !Array.isArray(record.args) || record.args.some(a => typeof a !== 'string'))
|
|
154
|
+
throw new Refusal('invalid_remote_request');
|
|
155
|
+
args = record.args;
|
|
156
|
+
}
|
|
157
|
+
command = ['list', 'send'].includes(args[0] ?? '') ? args[0] : 'unknown';
|
|
158
|
+
const options = parse(args);
|
|
159
|
+
if (wire && (options.values.has('--host') || options.values.has('--remote-bin')))
|
|
160
|
+
throw new Refusal('nested_transport_forbidden');
|
|
161
|
+
let message;
|
|
162
|
+
if (command === 'send') {
|
|
163
|
+
message = options.values.get('--message');
|
|
164
|
+
if (message === undefined || message === '-') {
|
|
165
|
+
if (wire)
|
|
166
|
+
throw new Refusal('remote_message_required');
|
|
167
|
+
message = await input();
|
|
168
|
+
}
|
|
169
|
+
message = envelope(message, options.flags.has('--no-from'), options.values.get('--reply-address'));
|
|
170
|
+
checkMessage(message, options.values.get('--to').startsWith('codex:'));
|
|
171
|
+
}
|
|
172
|
+
let result;
|
|
173
|
+
if (options.values.has('--host'))
|
|
174
|
+
result = await remote(options, message);
|
|
175
|
+
else if (command === 'send')
|
|
176
|
+
result = await send({ to: options.values.get('--to'), home: options.values.get('--codex-home'), codexBin: options.values.get('--codex-bin'), message: message, dryRun: options.flags.has('--dry-run') });
|
|
177
|
+
else
|
|
178
|
+
result = options.values.get('--agent') === 'claude' ? claude(options.flags.has('--all')) : await codex(options.values.get('--codex-home'), options.flags.has('--all'));
|
|
179
|
+
console.log(JSON.stringify({ schemaVersion: 1, host: hostname(), command, ok: result.ok !== false, version: VERSION, referenceVersion: '1.0.2', ...result }));
|
|
180
|
+
process.exitCode = result.ok === false ? 1 : 0;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
const unknown = error instanceof UnknownOutcome;
|
|
185
|
+
const failure = error instanceof Refusal ? error : new Refusal('operation_failed', 1);
|
|
186
|
+
console.log(JSON.stringify({ schemaVersion: 1, host: hostname(), command, ok: false,
|
|
187
|
+
error: unknown ? 'outcome_unknown' : failure.code, status: unknown ? 'unknown' : 'refused',
|
|
188
|
+
submitted: unknown ? null : false, consumptionConfirmed: false, retryAllowed: false }));
|
|
189
|
+
process.exitCode = unknown ? 1 : failure.exitCode;
|
|
190
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Deliberately read-only: no subprocess, network, socket connection or DB writes.
|
|
2
|
+
import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
export class Refusal extends Error {
|
|
6
|
+
code;
|
|
7
|
+
exitCode;
|
|
8
|
+
constructor(code, exitCode = 2) {
|
|
9
|
+
super(code);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.exitCode = exitCode;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function missing(error) {
|
|
15
|
+
return ['ENOENT', 'ENOTDIR'].includes(error.code ?? '');
|
|
16
|
+
}
|
|
17
|
+
export function canonical(path) {
|
|
18
|
+
if (path === '~' || path.startsWith('~/'))
|
|
19
|
+
path = join(homedir(), path.slice(2));
|
|
20
|
+
else if (path.startsWith('~'))
|
|
21
|
+
throw new Refusal('unsupported_home_expansion');
|
|
22
|
+
const absolute = resolve(path);
|
|
23
|
+
try {
|
|
24
|
+
return realpathSync(absolute);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (!missing(error))
|
|
28
|
+
throw new Refusal('home_resolution_failed', 1);
|
|
29
|
+
const parent = dirname(absolute);
|
|
30
|
+
return parent === absolute ? absolute : join(canonical(parent), absolute.slice(parent.length));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function alive(pid) {
|
|
34
|
+
if (pid <= 1)
|
|
35
|
+
return false;
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 0);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return error.code === 'EPERM';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function claude(all) {
|
|
45
|
+
const directory = join(process.env.CLAUDE_CONFIG_DIR || process.env.ANTHROPIC_CONFIG_DIR || join(homedir(), '.claude'), 'sessions');
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = readdirSync(directory).sort();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (missing(error))
|
|
52
|
+
entries = [];
|
|
53
|
+
else
|
|
54
|
+
throw new Refusal('claude_discovery_failed', 1);
|
|
55
|
+
}
|
|
56
|
+
const sessions = [];
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (!/^[0-9]+\.json$/.test(entry))
|
|
59
|
+
continue;
|
|
60
|
+
let record;
|
|
61
|
+
try {
|
|
62
|
+
const value = JSON.parse(readFileSync(join(directory, entry), 'utf8'));
|
|
63
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
64
|
+
continue;
|
|
65
|
+
record = value;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const pid = record.pid;
|
|
71
|
+
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid > 2147483647)
|
|
72
|
+
continue;
|
|
73
|
+
const matches = pid === Number(entry.slice(0, -5));
|
|
74
|
+
const live = matches && alive(pid);
|
|
75
|
+
const socket = typeof record.messagingSocketPath === 'string' ? record.messagingSocketPath : '';
|
|
76
|
+
let inbox = false;
|
|
77
|
+
if (socket) {
|
|
78
|
+
try {
|
|
79
|
+
inbox = statSync(socket).isSocket();
|
|
80
|
+
}
|
|
81
|
+
catch { /* Inaccessible is not reachable. */ }
|
|
82
|
+
}
|
|
83
|
+
const row = { agent: 'claude', pid, socket, alive: live, reachable: live && inbox };
|
|
84
|
+
for (const key of ['name', 'status', 'cwd', 'kind', 'version', 'tmux'])
|
|
85
|
+
row[key] = record[key] ?? null;
|
|
86
|
+
if (!matches)
|
|
87
|
+
row.staleReason = 'record_pid_mismatch';
|
|
88
|
+
if (all || row.reachable)
|
|
89
|
+
sessions.push(row);
|
|
90
|
+
}
|
|
91
|
+
return { sessions, discovery: { claude: { status: 'ok' } } };
|
|
92
|
+
}
|
|
93
|
+
export async function codex(path, all) {
|
|
94
|
+
const home = canonical(path);
|
|
95
|
+
const file = join(home, 'state_5.sqlite');
|
|
96
|
+
const item = { codexHome: home, stateDb: file, sources: ['argument'] };
|
|
97
|
+
let sessions = [];
|
|
98
|
+
let code;
|
|
99
|
+
try {
|
|
100
|
+
if (!statSync(file).isFile())
|
|
101
|
+
code = 'state_db_not_regular';
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
code = missing(error) ? 'state_db_missing' : 'state_db_read_failed';
|
|
105
|
+
}
|
|
106
|
+
if (!code) {
|
|
107
|
+
try {
|
|
108
|
+
// Lazy import: Claude-only commands don't initialize experimental SQLite.
|
|
109
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
110
|
+
const db = new DatabaseSync(file, { readOnly: true });
|
|
111
|
+
try {
|
|
112
|
+
db.exec('PRAGMA query_only=ON; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=3000');
|
|
113
|
+
const columns = new Set(db.prepare('PRAGMA table_info(threads)').all().map(row => row.name));
|
|
114
|
+
const required = ['id', 'title', 'cwd', 'updated_at', 'archived', 'rollout_path'];
|
|
115
|
+
if (!required.every(name => columns.has(name)))
|
|
116
|
+
throw new Error('unsupported_schema');
|
|
117
|
+
const name = columns.has('name') ? "COALESCE(NULLIF(name, ''), NULLIF(title, ''), id)" : "COALESCE(NULLIF(title, ''), id)";
|
|
118
|
+
const rows = db.prepare(`SELECT id, ${name} AS display_name, cwd, updated_at, archived FROM threads ${all ? '' : 'WHERE archived = 0'} ORDER BY updated_at DESC, id ASC`).all();
|
|
119
|
+
sessions = rows.map(row => {
|
|
120
|
+
if (typeof row.id !== 'string' || !row.id || typeof row.display_name !== 'string' ||
|
|
121
|
+
typeof row.updated_at !== 'number' || !Number.isSafeInteger(row.updated_at) ||
|
|
122
|
+
typeof row.archived !== 'number' || (row.cwd !== null && typeof row.cwd !== 'string')) {
|
|
123
|
+
throw new Error('unsupported_row');
|
|
124
|
+
}
|
|
125
|
+
// Python slicing counts Unicode code points, not JS UTF-16 units.
|
|
126
|
+
const firstLine = row.display_name.split(/[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]/u)[0] ?? '';
|
|
127
|
+
return { agent: 'codex', id: row.id, name: [...firstLine].slice(0, 120).join(''),
|
|
128
|
+
cwd: row.cwd, updatedAt: row.updated_at, archived: Boolean(row.archived),
|
|
129
|
+
codexHome: home, stateDb: file };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
db.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
code = 'state_db_read_failed';
|
|
138
|
+
sessions = [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const failed = Boolean(code);
|
|
142
|
+
Object.assign(item, failed ? { status: 'error', code, error: code } : { status: 'ok', sessionCount: sessions.length });
|
|
143
|
+
const discovery = { status: failed ? 'error' : 'ok', homes: [item] };
|
|
144
|
+
if (failed)
|
|
145
|
+
discovery.error = 'Codex home discovery is incomplete; inspect homes and errors';
|
|
146
|
+
return { sessions, codexHome: home, discovery: { codex: discovery }, ok: !failed,
|
|
147
|
+
...(failed ? { error: 'Discovery failed for: codex' } : {}) };
|
|
148
|
+
}
|
package/dist/process.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { accessSync, constants, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
4
|
+
import { Refusal } from './discovery.js';
|
|
5
|
+
export class UnknownOutcome extends Error {
|
|
6
|
+
}
|
|
7
|
+
export function executable(name) {
|
|
8
|
+
const paths = isAbsolute(name) ? [name] : name.includes('/') ? [] :
|
|
9
|
+
(process.env.PATH ?? '').split(delimiter).filter(Boolean).map(p => join(p, name));
|
|
10
|
+
for (const path of paths) {
|
|
11
|
+
try {
|
|
12
|
+
accessSync(path, constants.X_OK);
|
|
13
|
+
if (statSync(path).isFile())
|
|
14
|
+
return realpathSync(path);
|
|
15
|
+
}
|
|
16
|
+
catch { }
|
|
17
|
+
}
|
|
18
|
+
throw new Refusal('executable_unavailable', 1);
|
|
19
|
+
}
|
|
20
|
+
export function run(binary, args, options = {}) {
|
|
21
|
+
return new Promise(resolve => {
|
|
22
|
+
let stdout = '', bytes = 0, spawned = false, interrupted = false;
|
|
23
|
+
const child = spawn(binary, args, { shell: false, env: options.env ?? process.env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
24
|
+
const stop = () => { interrupted = true; child.kill('SIGKILL'); };
|
|
25
|
+
const timer = setTimeout(stop, options.timeout ?? 3000);
|
|
26
|
+
child.once('spawn', () => { spawned = true; });
|
|
27
|
+
child.stdin.on('error', () => { });
|
|
28
|
+
child.stdout.setEncoding('utf8');
|
|
29
|
+
child.stdout.on('data', (part) => {
|
|
30
|
+
bytes += Buffer.byteLength(part);
|
|
31
|
+
if (bytes > (options.limit ?? 1024 * 1024))
|
|
32
|
+
stop();
|
|
33
|
+
else
|
|
34
|
+
stdout += part;
|
|
35
|
+
});
|
|
36
|
+
// Drain but never report native stderr (it may contain message text/credentials).
|
|
37
|
+
child.stderr.on('data', (part) => { bytes += part.length; if (bytes > (options.limit ?? 1024 * 1024))
|
|
38
|
+
stop(); });
|
|
39
|
+
child.once('error', () => { interrupted = true; });
|
|
40
|
+
child.once('close', code => { clearTimeout(timer); resolve({ code, stdout, spawned, interrupted }); });
|
|
41
|
+
child.stdin.end(options.input);
|
|
42
|
+
});
|
|
43
|
+
}
|