@sakikotgw/pad-gateway-jump 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,45 @@
1
- # Temporary Holding Version
1
+ # @sakikotgw/pad-gateway-jump
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ PAD 的 **0.2.x 跳转专用 carrier**:在 DSH host 里起一个 loopback HTTP,只服务会话跳转通道,写与完整 `pad-gateway` 相同的 `<DSH_HOME>/pad-gateway.json` 广告,供 `@sakikotgw/pad-session-jump` 长轮询。
4
+
5
+ ## 为什么单独一个包
6
+
7
+ 完整 `@sakikotgw/pad-gateway` 会挂官方 apiproxy(`/api`),依赖 `@deepseek-ai/dsh-host-apiproxy` 并要求 `engines.dsh >=0.1.0-rc.7 <0.2.0`。但 **0.2.x 没有对应的 npm apiproxy**:装不上、bundle patch 里的 apiproxy 行也加载不了。本包只带跳转通道,`engines.dsh` 放宽到 `>=0.1.0-rc.7`,用于 0.2.x(也兼容 0.1.x,但 0.1.x 应用完整版 gateway 以保留 `/api`)。
8
+
9
+ ## 端点
10
+
11
+ | 方法 | 路径 | 鉴权 | 说明 |
12
+ |---|---|---|---|
13
+ | GET | `/pad/ping` | Bearer | `{ok, pid, profile, schema, capabilities:{jump:true,api:false}}` |
14
+ | POST | `/pad/jump` | Bearer | 入队:`{sessionId}` → `{ok, seq}` |
15
+ | GET | `/pad/jump?since=N` | Bearer | 长轮询(25s 超时 204) |
16
+ | POST | `/pad/jump/ack` | Bearer | 消费回执 |
17
+ | * | `/api/*` | — | 固定 503:本包不代理 apiproxy |
18
+
19
+ CORS 只放行 loopback origin 与 `dsh-app://app`。队列语义(TTL 30s、容量 20、seq 单调)与完整版一致。
20
+
21
+ ## 安装(0.2.x 实例)
22
+
23
+ ```powershell
24
+ dsh plugin --profile web add file:E:\pack-agent\agent-pack-dsh\gateway-jump
25
+ ```
26
+
27
+ 装完重启 `dsh web`;`<DSH_HOME>/pad-gateway.json` 会出现,`capabilities.api=false`。
28
+
29
+ ## 测试与同步
30
+
31
+ ```powershell
32
+ node --test test/jump.test.mjs # 7 pass(与完整版共用实现)
33
+ node scripts/sync-from-gateway.mjs # 从 ../gateway 同步 lib/jump.mjs 与测试
34
+ ```
35
+
36
+ `lib/jump.mjs` / `test/jump.test.mjs` 是生成的副本,改实现请改 `../gateway` 后跑 sync。
37
+
38
+ ## 与完整 gateway 的选择
39
+
40
+ | 实例 DSH | 用哪个 |
41
+ |---|---|
42
+ | `0.1.x` | `@sakikotgw/pad-gateway`(含 `/api` 代理 + 跳转) |
43
+ | `0.2.x` | `@sakikotgw/pad-gateway-jump`(仅跳转;`/api` 不可用) |
44
+
45
+ PAD 侧按实例钉的 DSH 版本选择安装哪个包(待 M1 接线)。
@@ -0,0 +1,6 @@
1
+ # pad-gateway-jump bundle patch: mounts the jump-only carrier row.
2
+ # Deliberately does NOT insert apiproxy / directory-picker rows: dsh 0.2.x
3
+ # has no matching npm package for them, and the jump channel needs neither.
4
+ - insert:
5
+ - id: pad-gateway-jump
6
+ name: '@sakikotgw/pad-gateway-jump'
package/lib/index.js ADDED
@@ -0,0 +1,138 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { appendFileSync, chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { createJumpHttpHandler, createJumpStore } from "./jump.mjs";
6
+
7
+ /**
8
+ * Jump-only loopback carrier for DeepSeek Harness 0.2.x.
9
+ *
10
+ * The full `@sakikotgw/pad-gateway` mounts the official apiproxy too; on 0.2.x
11
+ * apiproxy has no matching npm package, so installing it is refused and its
12
+ * bundle patch cannot load. This carrier serves only the jump channel:
13
+ *
14
+ * GET /pad/ping
15
+ * POST /pad/jump
16
+ * GET /pad/jump?since=N
17
+ * POST /pad/jump/ack
18
+ *
19
+ * It writes the same `<DSH_HOME>/pad-gateway.json` ad shape (url points to
20
+ * `/api` for compatibility with PAD's ad reader; endpoints live on the origin).
21
+ */
22
+ const name = "pad-gateway-jump";
23
+ const inject = [];
24
+ const GATEWAY_FILE = "pad-gateway.json";
25
+ const GATEWAY_LOG = "pad-gateway.log";
26
+ const GATEWAY_SCHEMA = "pack-agent.pad-gateway/v1";
27
+
28
+ function tokenOk(got, expected) {
29
+ const a = Buffer.from(got);
30
+ const b = Buffer.from(expected);
31
+ return a.length === b.length && timingSafeEqual(a, b);
32
+ }
33
+
34
+ function bearer(req) {
35
+ const raw = req.headers.authorization;
36
+ if (typeof raw !== "string") return "";
37
+ return raw.startsWith("Bearer ") ? raw.slice(7).trim() : "";
38
+ }
39
+
40
+ function apply(ctx, config = {}) {
41
+ const home = (config.home ?? process.env.DSH_HOME ?? "").trim();
42
+ const trace = (line) => {
43
+ const stamped = `${new Date().toISOString()} ${line}\n`;
44
+ ctx.logger?.info?.(line);
45
+ if (!home) {
46
+ process.stderr.write(`[pad-gateway-jump] ${stamped}`);
47
+ return;
48
+ }
49
+ try {
50
+ appendFileSync(join(home, GATEWAY_LOG), stamped);
51
+ } catch {}
52
+ };
53
+ if (!home) {
54
+ trace("no DSH_HOME, pad gateway-jump not started");
55
+ return;
56
+ }
57
+ const token = randomBytes(32).toString("hex");
58
+ const file = join(home, GATEWAY_FILE);
59
+ const jumpStore = createJumpStore({
60
+ ttlMs: Number(process.env.PAD_GATEWAY_JUMP_TTL_MS ?? 0) || undefined,
61
+ timeoutMs: Number(process.env.PAD_GATEWAY_JUMP_TIMEOUT_MS ?? 0) || undefined,
62
+ });
63
+ const handleJump = createJumpHttpHandler({ store: jumpStore, token });
64
+ let server;
65
+ trace(`apply() in pid ${process.pid}`);
66
+ const boot = async () => {
67
+ server = createServer((req, res) => {
68
+ (async () => {
69
+ try {
70
+ if (await handleJump(req, res)) return;
71
+ if (!tokenOk(bearer(req), token)) {
72
+ res.writeHead(401, { "content-type": "application/json" });
73
+ res.end(JSON.stringify({ error: "unauthorized" }));
74
+ return;
75
+ }
76
+ const path = (req.url ?? "/").split("?")[0];
77
+ if (path === "/pad/ping") {
78
+ res.writeHead(200, { "content-type": "application/json" });
79
+ res.end(
80
+ JSON.stringify({
81
+ ok: true,
82
+ pid: process.pid,
83
+ profile: process.env.DSH_PROFILE ?? null,
84
+ schema: GATEWAY_SCHEMA,
85
+ capabilities: { jump: true, api: false },
86
+ }),
87
+ );
88
+ return;
89
+ }
90
+ res.writeHead(503, { "content-type": "application/json" });
91
+ res.end(JSON.stringify({ error: "apiproxy not part of pad-gateway-jump" }));
92
+ } catch (err) {
93
+ if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
94
+ res.end(String(err instanceof Error ? err.message : err));
95
+ }
96
+ })();
97
+ });
98
+ await new Promise((resolve, reject) => {
99
+ server.once("error", reject);
100
+ server.listen(0, "127.0.0.1", () => resolve());
101
+ });
102
+ const addr = server.address();
103
+ if (!addr || typeof addr === "string") throw new Error("pad gateway-jump failed to bind");
104
+ const ad = {
105
+ schema: GATEWAY_SCHEMA,
106
+ url: `http://127.0.0.1:${addr.port}/api`,
107
+ token,
108
+ pid: process.pid,
109
+ capabilities: { jump: true, api: false },
110
+ ...(process.env.DSH_PROFILE ? { profile: process.env.DSH_PROFILE } : {}),
111
+ };
112
+ writeFileSync(file, `${JSON.stringify(ad, null, 2)}\n`, { encoding: "utf8", mode: 384 });
113
+ try {
114
+ chmodSync(file, 384);
115
+ } catch {}
116
+ trace(`listening on ${ad.url} (jump channel ready, api disabled)`);
117
+ };
118
+ boot().catch((err) => {
119
+ const detail = err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err);
120
+ trace(`boot failed: ${detail}`);
121
+ ctx.logger?.warn?.(`[pack-agent] pad gateway-jump failed: ${detail}`);
122
+ });
123
+ ctx.on?.("dispose", () => {
124
+ jumpStore.dispose();
125
+ server?.close();
126
+ try {
127
+ if (existsSync(file)) {
128
+ const cur = JSON.parse(readFileSync(file, "utf8"));
129
+ if (cur.pid === process.pid && cur.token === token) rmSync(file, { force: true });
130
+ }
131
+ } catch {
132
+ rmSync(file, { force: true });
133
+ }
134
+ });
135
+ }
136
+
137
+ export { apply, inject, name, GATEWAY_SCHEMA, GATEWAY_FILE, GATEWAY_LOG };
138
+ export default apply;
package/lib/jump.mjs ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * pad-gateway jump channel (host side).
3
+ *
4
+ * A tiny authenticated queue used by PAD to hand a session target to the page
5
+ * runtime: PAD POSTs an event, the page's client plugin long-polls it, then
6
+ * calls `sessions.open()`. Reuses the gateway's loopback HTTP carrier + bearer
7
+ * token — no extra port, no extra process.
8
+ *
9
+ * Transport contract (see agent-pack-dsh/docs/session-jump.md §7.1):
10
+ * POST /pad/jump {sessionId} -> {ok, seq}
11
+ * GET /pad/jump?since=N -> event JSON | 204 (timeout)
12
+ * POST /pad/jump/ack {seq, ok} -> {ok:true}
13
+ * OPTIONS /pad/jump* -> CORS preflight
14
+ *
15
+ * The page origin may be a loopback http(s) origin (browser) or `dsh-app://app`
16
+ * (official desktop renderer); anything else is rejected.
17
+ */
18
+ import { timingSafeEqual } from "node:crypto";
19
+
20
+ export const JUMP_SCHEMA = "pack-agent.pad-jump/v1";
21
+ export const JUMP_DEFAULT_TTL_MS = 30_000;
22
+ export const JUMP_DEFAULT_TIMEOUT_MS = 25_000;
23
+ export const JUMP_MAX_QUEUE = 20;
24
+ export const JUMP_SESSION_ID = /^session-[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;
25
+
26
+ const LOOPBACK_ORIGIN = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/;
27
+ const DESKTOP_ORIGIN = "dsh-app://app";
28
+
29
+ /**
30
+ * @param {string} id raw session id
31
+ * @returns {boolean} whether it is a canonical DSH session id
32
+ */
33
+ export function isSessionId(id) {
34
+ return typeof id === "string" && JUMP_SESSION_ID.test(id);
35
+ }
36
+
37
+ /**
38
+ * @param {string} origin request Origin header
39
+ * @returns {boolean} whether the origin may read jump responses
40
+ */
41
+ export function isAllowedOrigin(origin) {
42
+ if (typeof origin !== "string" || origin === "") return false;
43
+ return origin === DESKTOP_ORIGIN || LOOPBACK_ORIGIN.test(origin);
44
+ }
45
+
46
+ /**
47
+ * Create the in-memory jump queue.
48
+ * @param {{ttlMs?: number, timeoutMs?: number, now?: () => number}} [options]
49
+ */
50
+ export function createJumpStore(options = {}) {
51
+ const ttlMs = options.ttlMs ?? JUMP_DEFAULT_TTL_MS;
52
+ const timeoutMs = options.timeoutMs ?? JUMP_DEFAULT_TIMEOUT_MS;
53
+ const now = options.now ?? Date.now;
54
+ /** @type {{schema: string, seq: number, sessionId: string, at: number}[]} */
55
+ const events = [];
56
+ const waiters = new Set();
57
+ let seq = 0;
58
+
59
+ function prune(at) {
60
+ while (events.length > 0 && at - events[0].at > ttlMs) events.shift();
61
+ }
62
+
63
+ /**
64
+ * Enqueue one jump event and wake every waiting long-poll.
65
+ * @param {string} sessionId canonical session id
66
+ */
67
+ function publish(sessionId) {
68
+ if (!isSessionId(sessionId)) throw new Error("pad-jump: invalid session id");
69
+ const at = now();
70
+ prune(at);
71
+ const event = { schema: JUMP_SCHEMA, seq: ++seq, sessionId, at };
72
+ events.push(event);
73
+ if (events.length > JUMP_MAX_QUEUE) events.splice(0, events.length - JUMP_MAX_QUEUE);
74
+ for (const waiter of [...waiters]) waiter.settle(event);
75
+ return event;
76
+ }
77
+
78
+ /**
79
+ * Long-poll for the first event after `since`.
80
+ * @param {number} since last consumed seq
81
+ * @returns {Promise<object|null>} event, or null on timeout/dispose
82
+ */
83
+ function wait(since) {
84
+ const at = now();
85
+ prune(at);
86
+ const found = events.find((event) => event.seq > since);
87
+ if (found !== undefined) return Promise.resolve(found);
88
+ return new Promise((resolve) => {
89
+ const waiter = {
90
+ settle(event) {
91
+ clearTimeout(timer);
92
+ waiters.delete(waiter);
93
+ resolve(event);
94
+ },
95
+ };
96
+ const timer = setTimeout(() => {
97
+ waiters.delete(waiter);
98
+ resolve(null);
99
+ }, timeoutMs);
100
+ timer.unref?.();
101
+ waiters.add(waiter);
102
+ });
103
+ }
104
+
105
+ function dispose() {
106
+ for (const waiter of [...waiters]) waiter.settle(null);
107
+ events.length = 0;
108
+ }
109
+
110
+ return {
111
+ publish,
112
+ wait,
113
+ dispose,
114
+ get lastSeq() {
115
+ return seq;
116
+ },
117
+ get size() {
118
+ return events.length;
119
+ },
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Build the HTTP handler for the jump endpoints.
125
+ * @param {{store: ReturnType<typeof createJumpStore>, token: string}} options
126
+ * @returns {(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => Promise<boolean>}
127
+ */
128
+ export function createJumpHttpHandler({ store, token }) {
129
+ return async function handleJump(req, res) {
130
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
131
+ if (!url.pathname.startsWith("/pad/jump")) return false;
132
+
133
+ const origin = req.headers.origin;
134
+ if (typeof origin === "string" && origin !== "") {
135
+ if (!isAllowedOrigin(origin)) {
136
+ sendJson(res, 403, { error: "origin not allowed" });
137
+ return true;
138
+ }
139
+ res.setHeader("access-control-allow-origin", origin);
140
+ res.setHeader("vary", "origin");
141
+ res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
142
+ res.setHeader("access-control-allow-headers", "authorization, content-type");
143
+ res.setHeader("access-control-max-age", "600");
144
+ }
145
+ if (req.method === "OPTIONS") {
146
+ res.writeHead(204);
147
+ res.end();
148
+ return true;
149
+ }
150
+ if (!authorized(req.headers.authorization, token)) {
151
+ sendJson(res, 401, { error: "unauthorized" });
152
+ return true;
153
+ }
154
+
155
+ if (url.pathname === "/pad/jump" && req.method === "POST") {
156
+ let body;
157
+ try {
158
+ body = await readJson(req);
159
+ } catch {
160
+ sendJson(res, 400, { error: "invalid json" });
161
+ return true;
162
+ }
163
+ const sessionId = body?.sessionId;
164
+ if (!isSessionId(sessionId)) {
165
+ sendJson(res, 400, { error: "invalid session id" });
166
+ return true;
167
+ }
168
+ const event = store.publish(sessionId);
169
+ sendJson(res, 200, { ok: true, seq: event.seq });
170
+ return true;
171
+ }
172
+
173
+ if (url.pathname === "/pad/jump" && req.method === "GET") {
174
+ const since = Number(url.searchParams.get("since") ?? "0");
175
+ const event = await store.wait(Number.isFinite(since) ? since : 0);
176
+ if (event === null) {
177
+ res.writeHead(204);
178
+ res.end();
179
+ return true;
180
+ }
181
+ sendJson(res, 200, event);
182
+ return true;
183
+ }
184
+
185
+ if (url.pathname === "/pad/jump/ack" && req.method === "POST") {
186
+ try {
187
+ await readJson(req);
188
+ } catch {
189
+ sendJson(res, 400, { error: "invalid json" });
190
+ return true;
191
+ }
192
+ sendJson(res, 200, { ok: true });
193
+ return true;
194
+ }
195
+
196
+ sendJson(res, 404, { error: "not found" });
197
+ return true;
198
+ };
199
+ }
200
+
201
+ function authorized(header, token) {
202
+ if (typeof header !== "string" || !header.startsWith("Bearer ")) return false;
203
+ const got = header.slice(7).trim();
204
+ const a = Buffer.from(got);
205
+ const b = Buffer.from(token);
206
+ return a.length === b.length && timingSafeEqual(a, b);
207
+ }
208
+
209
+ function sendJson(res, status, value) {
210
+ res.writeHead(status, { "content-type": "application/json" });
211
+ res.end(JSON.stringify(value));
212
+ }
213
+
214
+ function readJson(req) {
215
+ return new Promise((resolve, reject) => {
216
+ const chunks = [];
217
+ let size = 0;
218
+ req.on("data", (chunk) => {
219
+ size += chunk.length;
220
+ if (size > 64 * 1024) {
221
+ reject(new Error("body too large"));
222
+ req.destroy();
223
+ return;
224
+ }
225
+ chunks.push(chunk);
226
+ });
227
+ req.on("end", () => {
228
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
229
+ if (raw === "") return resolve({});
230
+ try {
231
+ resolve(JSON.parse(raw));
232
+ } catch (error) {
233
+ reject(error);
234
+ }
235
+ });
236
+ req.on("error", reject);
237
+ });
238
+ }
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@sakikotgw/pad-gateway-jump",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "PAD jump-only loopback carrier for DeepSeek Harness 0.2.x: session jump events over the same pad-gateway.json contract (no apiproxy).",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js"
9
+ },
10
+ "license": "MIT",
11
+ "author": "sakikotgw",
12
+ "dsh": {
13
+ "manifestVersion": 1,
14
+ "bundle": {
15
+ "patch": "./cordis.patch.yml"
16
+ }
17
+ },
18
+ "engines": {
19
+ "dsh": ">=0.1.0-rc.7"
20
+ },
21
+ "files": [
22
+ "lib",
23
+ "cordis.patch.yml",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "test": "node --test test/jump.test.mjs",
28
+ "sync": "node scripts/sync-from-gateway.mjs"
29
+ }
30
+ }