@wenrwa/marketplace-cli 0.7.0 → 0.7.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/dist/__tests__/bridge-attach-file.test.d.ts +1 -0
- package/dist/__tests__/bridge-attach-file.test.js +194 -0
- package/dist/bridge/attachmentWriter.d.ts +54 -0
- package/dist/bridge/attachmentWriter.js +172 -0
- package/dist/bridge/relayClient.js +7 -0
- package/dist/bridge/server.js +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
/**
|
|
37
|
+
* Terminal attach — `attach-file` saves an image next to the session's agent
|
|
38
|
+
* and answers `file-attached` with its absolute path. Both lanes: the local
|
|
39
|
+
* loopback server and the relay client (hosted container) share one handler,
|
|
40
|
+
* and both are exercised here end to end against a REAL temp folder so the
|
|
41
|
+
* bytes on disk are compared with the bytes sent.
|
|
42
|
+
*/
|
|
43
|
+
const ws_1 = __importStar(require("ws"));
|
|
44
|
+
const fs = __importStar(require("fs"));
|
|
45
|
+
const os = __importStar(require("os"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
const server_1 = require("../bridge/server");
|
|
48
|
+
const relayClient_1 = require("../bridge/relayClient");
|
|
49
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
50
|
+
const attachmentWriter_1 = require("../bridge/attachmentWriter");
|
|
51
|
+
class FakePty {
|
|
52
|
+
written = [];
|
|
53
|
+
write(d) { this.written.push(d); }
|
|
54
|
+
resize() { }
|
|
55
|
+
kill() { }
|
|
56
|
+
onData() { }
|
|
57
|
+
onExit() { }
|
|
58
|
+
}
|
|
59
|
+
// A real (tiny) PNG: signature + IHDR + IDAT + IEND for a 1x1 pixel, plus
|
|
60
|
+
// every byte value so a lossy encoding would be caught.
|
|
61
|
+
const PNG_1PX = Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8cfc0f01f0005000201a5f3b4d60000000049454e44ae426082', 'hex');
|
|
62
|
+
const PNG = Buffer.concat([PNG_1PX, Buffer.from(Array.from({ length: 256 }, (_, i) => i))]);
|
|
63
|
+
function frameQueue(ws) {
|
|
64
|
+
const frames = [];
|
|
65
|
+
const waiters = [];
|
|
66
|
+
ws.on('message', (raw) => {
|
|
67
|
+
const f = JSON.parse(String(raw));
|
|
68
|
+
const w = waiters.shift();
|
|
69
|
+
if (w)
|
|
70
|
+
w(f);
|
|
71
|
+
else
|
|
72
|
+
frames.push(f);
|
|
73
|
+
});
|
|
74
|
+
return { next: () => (frames.length ? Promise.resolve(frames.shift()) : new Promise((r) => waiters.push(r))) };
|
|
75
|
+
}
|
|
76
|
+
let tmp;
|
|
77
|
+
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'attach-'))); });
|
|
78
|
+
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
79
|
+
describe('attachmentWriter — pure rules', () => {
|
|
80
|
+
it('sniffs the type from magic bytes, never the name', () => {
|
|
81
|
+
expect((0, attachmentWriter_1.sniffImageExt)(PNG)).toBe('png');
|
|
82
|
+
expect((0, attachmentWriter_1.sniffImageExt)(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0]))).toBe('jpg');
|
|
83
|
+
expect((0, attachmentWriter_1.sniffImageExt)(Buffer.from('GIF89a......'))).toBe('gif');
|
|
84
|
+
expect((0, attachmentWriter_1.sniffImageExt)(Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBPVP8 ')]))).toBe('webp');
|
|
85
|
+
expect((0, attachmentWriter_1.sniffImageExt)(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'))).toBeNull();
|
|
86
|
+
expect((0, attachmentWriter_1.sniffImageExt)(Buffer.from('%PDF-1.7'))).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
it('makes a stem that needs no escaping and cannot traverse', () => {
|
|
89
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('Screenshot 2026-10-04 at 9.41.12 AM.png')).toBe('Screenshot-2026-10-04-at-9.41.12-AM');
|
|
90
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('../../etc/passwd')).toBe('passwd');
|
|
91
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('..\\..\\x.png')).toBe('x');
|
|
92
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('.hidden.png')).toBe('hidden');
|
|
93
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('日本語.png')).toBe('image');
|
|
94
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('')).toBe('image');
|
|
95
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('a'.repeat(90) + '.png')).toHaveLength(40);
|
|
96
|
+
});
|
|
97
|
+
it('writes byte-identical bytes under .hiveop/attachments with a self-ignoring .gitignore', () => {
|
|
98
|
+
const r = (0, attachmentWriter_1.writeAttachment)({ root: tmp, name: 'shot one.png', dataBase64: PNG.toString('base64') });
|
|
99
|
+
expect(r.ok).toBe(true);
|
|
100
|
+
if (!r.ok)
|
|
101
|
+
return;
|
|
102
|
+
expect(path.dirname(r.path)).toBe(path.join(tmp, attachmentWriter_1.ATTACHMENTS_DIR));
|
|
103
|
+
expect(path.basename(r.path)).toMatch(/^\d{8}-\d{6}-[0-9a-f]{4}-shot-one\.png$/);
|
|
104
|
+
expect(fs.readFileSync(r.path).equals(PNG)).toBe(true);
|
|
105
|
+
expect(fs.readFileSync(path.join(tmp, attachmentWriter_1.ATTACHMENTS_DIR, '.gitignore'), 'utf8')).toBe('*\n');
|
|
106
|
+
// No temp files left behind.
|
|
107
|
+
expect(fs.readdirSync(path.join(tmp, attachmentWriter_1.ATTACHMENTS_DIR)).filter((f) => f.endsWith('.tmp'))).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
it('names the extension from the bytes even when the name lies', () => {
|
|
110
|
+
const r = (0, attachmentWriter_1.writeAttachment)({ root: tmp, name: 'photo.jpg', dataBase64: PNG.toString('base64') });
|
|
111
|
+
expect(r.ok && r.path.endsWith('-photo.png')).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
it('refuses non-images and oversize files without writing anything', () => {
|
|
114
|
+
expect((0, attachmentWriter_1.writeAttachment)({ root: tmp, name: 'x.png', dataBase64: Buffer.from('not an image').toString('base64') }))
|
|
115
|
+
.toEqual({ ok: false, reason: 'not-image' });
|
|
116
|
+
const big = Buffer.concat([PNG_1PX, Buffer.alloc(attachmentWriter_1.MAX_ATTACHMENT_BYTES)]);
|
|
117
|
+
expect((0, attachmentWriter_1.writeAttachment)({ root: tmp, name: 'x.png', dataBase64: big.toString('base64') }))
|
|
118
|
+
.toEqual({ ok: false, reason: 'too-large' });
|
|
119
|
+
expect(fs.existsSync(path.join(tmp, '.hiveop'))).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
it('refuses to follow a planted .hiveop symlink out of the project', () => {
|
|
122
|
+
const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'outside-')));
|
|
123
|
+
try {
|
|
124
|
+
fs.mkdirSync(path.join(outside, 'attachments'));
|
|
125
|
+
fs.symlinkSync(outside, path.join(tmp, '.hiveop'));
|
|
126
|
+
expect((0, attachmentWriter_1.writeAttachment)({ root: tmp, name: 'x.png', dataBase64: PNG.toString('base64') }))
|
|
127
|
+
.toEqual({ ok: false, reason: 'error' });
|
|
128
|
+
expect(fs.readdirSync(path.join(outside, 'attachments'))).toEqual([]);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
it('refuses an unknown session (the session decides the folder, not the frame)', () => {
|
|
135
|
+
const sessions = { list: () => ['a'], cwdOf: () => tmp };
|
|
136
|
+
expect((0, attachmentWriter_1.handleAttachFrame)({ rid: 'r', sessionId: 'b', data: PNG.toString('base64') }, sessions))
|
|
137
|
+
.toEqual({ t: 'file-attached', rid: 'r', sessionId: 'b', ok: false, reason: 'no-session' });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe('local bridge server — attach-file frame', () => {
|
|
141
|
+
let handle;
|
|
142
|
+
let sessions;
|
|
143
|
+
let ptys;
|
|
144
|
+
beforeEach(async () => {
|
|
145
|
+
ptys = [];
|
|
146
|
+
const factory = { spawn: () => { const p = new FakePty(); ptys.push(p); return p; } };
|
|
147
|
+
sessions = new sessionManager_1.SessionManager(factory);
|
|
148
|
+
handle = await (0, server_1.startBridgeServer)({ sessionToken: 'tok', allowedOrigin: 'https://app.hiveop.io', sessions });
|
|
149
|
+
});
|
|
150
|
+
afterEach(async () => { await handle.close(); });
|
|
151
|
+
it('writes next to the session and replies with the absolute path; nothing reaches the PTY', async () => {
|
|
152
|
+
const sessionId = sessions.open({ cols: 80, rows: 24, cwd: tmp, id: 'agent-1' });
|
|
153
|
+
const ws = new ws_1.default(`ws://127.0.0.1:${handle.port}`, { headers: { origin: 'https://app.hiveop.io' } });
|
|
154
|
+
await new Promise((r) => ws.on('open', r));
|
|
155
|
+
const q = frameQueue(ws);
|
|
156
|
+
ws.send(JSON.stringify({ v: 1, token: 'tok' }));
|
|
157
|
+
ws.send(JSON.stringify({ t: 'attach-file', rid: 'r1', sessionId, name: 'Screen Shot.png', data: PNG.toString('base64') }));
|
|
158
|
+
const reply = await q.next();
|
|
159
|
+
expect(reply).toMatchObject({ t: 'file-attached', rid: 'r1', sessionId, ok: true, bytes: PNG.length });
|
|
160
|
+
expect(reply.path.startsWith(path.join(tmp, attachmentWriter_1.ATTACHMENTS_DIR) + path.sep)).toBe(true);
|
|
161
|
+
expect(fs.readFileSync(reply.path).equals(PNG)).toBe(true);
|
|
162
|
+
expect(ptys[0].written).toEqual([]);
|
|
163
|
+
ws.close();
|
|
164
|
+
});
|
|
165
|
+
it('advertises the terminal-attach capability in /discovery', async () => {
|
|
166
|
+
const res = await fetch(`http://127.0.0.1:${handle.port}/discovery`, { headers: { origin: 'https://app.hiveop.io' } });
|
|
167
|
+
const body = await res.json();
|
|
168
|
+
expect(body.capabilities).toContain('terminal-attach');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
describe('relay client (hosted container) — attach-file frame', () => {
|
|
172
|
+
it('writes next to the relay session and echoes rid + sessionId for demux', async () => {
|
|
173
|
+
const wss = new ws_1.WebSocketServer({ port: 0, host: '127.0.0.1' });
|
|
174
|
+
await new Promise((r) => wss.on('listening', r));
|
|
175
|
+
const port = wss.address().port;
|
|
176
|
+
const conn = new Promise((r) => wss.on('connection', r));
|
|
177
|
+
const sessions = new sessionManager_1.SessionManager({ spawn: () => new FakePty() });
|
|
178
|
+
sessions.open({ cols: 80, rows: 24, cwd: tmp, id: 'agent-H' });
|
|
179
|
+
const client = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${port}`, relayToken: 'tok', sessions });
|
|
180
|
+
const ws = await conn;
|
|
181
|
+
const q = frameQueue(ws);
|
|
182
|
+
await q.next(); // auth
|
|
183
|
+
ws.send(JSON.stringify({ t: 'attach-file', rid: 'h1', sessionId: 'agent-H', name: 'x.png', data: PNG.toString('base64') }));
|
|
184
|
+
const reply = await q.next();
|
|
185
|
+
expect(reply).toMatchObject({ t: 'file-attached', rid: 'h1', sessionId: 'agent-H', ok: true });
|
|
186
|
+
expect(fs.readFileSync(reply.path).equals(PNG)).toBe(true);
|
|
187
|
+
ws.send(JSON.stringify({ t: 'attach-file', rid: 'h2', sessionId: 'agent-H', name: 'x.txt', data: Buffer.from('hello').toString('base64') }));
|
|
188
|
+
expect(await q.next()).toMatchObject({ t: 'file-attached', rid: 'h2', ok: false, reason: 'not-image' });
|
|
189
|
+
client.close();
|
|
190
|
+
for (const c of wss.clients)
|
|
191
|
+
c.terminate();
|
|
192
|
+
await new Promise((r) => wss.close(r));
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export declare const ATTACHMENTS_DIR: string;
|
|
2
|
+
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
3
|
+
export type ImageExt = 'png' | 'jpg' | 'gif' | 'webp';
|
|
4
|
+
export type AttachmentReason = 'not-image' | 'too-large' | 'no-session' | 'error';
|
|
5
|
+
export type AttachmentResult = {
|
|
6
|
+
ok: true;
|
|
7
|
+
path: string;
|
|
8
|
+
bytes: number;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
reason: AttachmentReason;
|
|
12
|
+
};
|
|
13
|
+
/** The image type, read from the bytes themselves — never from the name. */
|
|
14
|
+
export declare function sniffImageExt(buf: Buffer): ImageExt | null;
|
|
15
|
+
/**
|
|
16
|
+
* A filename stem that is safe on every filesystem AND needs no shell
|
|
17
|
+
* escaping in a prompt: letters, digits, dot, dash, underscore only; no
|
|
18
|
+
* leading dot; at most 40 chars; never empty.
|
|
19
|
+
*/
|
|
20
|
+
export declare function safeAttachmentStem(name: string): string;
|
|
21
|
+
export interface AttachmentDeps {
|
|
22
|
+
mkdirp: (dir: string) => void;
|
|
23
|
+
exists: (p: string) => boolean;
|
|
24
|
+
realpath: (p: string) => string;
|
|
25
|
+
/** Real impl: temp file + rename, so Claude never reads half an image. */
|
|
26
|
+
writeFileAtomic: (p: string, data: Buffer) => void;
|
|
27
|
+
now: () => Date;
|
|
28
|
+
random: () => string;
|
|
29
|
+
}
|
|
30
|
+
export declare const realAttachmentDeps: AttachmentDeps;
|
|
31
|
+
/** Write one image under `<root>/.hiveop/attachments/` and return its absolute path. */
|
|
32
|
+
export declare function writeAttachment(input: {
|
|
33
|
+
root: string;
|
|
34
|
+
name: string;
|
|
35
|
+
dataBase64: string;
|
|
36
|
+
}, deps?: AttachmentDeps): AttachmentResult;
|
|
37
|
+
/** Where a session's attachments go: its project folder, else the bridge user's home. */
|
|
38
|
+
export declare function attachmentRootFor(sessions: {
|
|
39
|
+
list(): string[];
|
|
40
|
+
cwdOf(id: string): string | undefined;
|
|
41
|
+
}, sessionId: unknown): string | null;
|
|
42
|
+
/**
|
|
43
|
+
* The whole `attach-file` frame → `file-attached` reply, shared verbatim by
|
|
44
|
+
* the local server and the relay client so the two lanes cannot drift.
|
|
45
|
+
*/
|
|
46
|
+
export declare function handleAttachFrame(frame: {
|
|
47
|
+
rid?: unknown;
|
|
48
|
+
sessionId?: unknown;
|
|
49
|
+
name?: unknown;
|
|
50
|
+
data?: unknown;
|
|
51
|
+
}, sessions: {
|
|
52
|
+
list(): string[];
|
|
53
|
+
cwdOf(id: string): string | undefined;
|
|
54
|
+
}, deps?: AttachmentDeps): Record<string, unknown>;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.realAttachmentDeps = exports.MAX_ATTACHMENT_BYTES = exports.ATTACHMENTS_DIR = void 0;
|
|
37
|
+
exports.sniffImageExt = sniffImageExt;
|
|
38
|
+
exports.safeAttachmentStem = safeAttachmentStem;
|
|
39
|
+
exports.writeAttachment = writeAttachment;
|
|
40
|
+
exports.attachmentRootFor = attachmentRootFor;
|
|
41
|
+
exports.handleAttachFrame = handleAttachFrame;
|
|
42
|
+
/**
|
|
43
|
+
* Terminal attach — save a screenshot NEXT TO the agent that will read it.
|
|
44
|
+
*
|
|
45
|
+
* Claude Code attaches an image when that image's file path lands in its
|
|
46
|
+
* prompt. So the browser ships the image bytes over the pane's own socket
|
|
47
|
+
* (`attach-file` frame — the local bridge's loopback WS, or the relay into the
|
|
48
|
+
* hosted container; same frame, same handler, both lanes) and this module
|
|
49
|
+
* writes them into the SESSION's project folder:
|
|
50
|
+
*
|
|
51
|
+
* <session cwd>/.hiveop/attachments/<stamp>-<rand>-<safe-name>.<ext>
|
|
52
|
+
*
|
|
53
|
+
* Why there:
|
|
54
|
+
* - `.hiveop/` is already the bridge-owned dir inside a checkout
|
|
55
|
+
* (`.hiveop/context.md`), excluded from the tree scanner/watcher and from
|
|
56
|
+
* the checkout inventory's "unsaved work" count — no new convention.
|
|
57
|
+
* - It is inside Claude's cwd, so a follow-up Read of the file needs no
|
|
58
|
+
* out-of-project permission prompt on either lane.
|
|
59
|
+
* - The attachments dir carries its OWN `.gitignore` (`*`), so an agent's
|
|
60
|
+
* `git add -A` never commits screenshots — without editing the user's
|
|
61
|
+
* .gitignore.
|
|
62
|
+
*
|
|
63
|
+
* The browser's name and type are hints only: the extension comes from the
|
|
64
|
+
* file's magic bytes, and anything that is not png/jpeg/gif/webp is refused.
|
|
65
|
+
* The session — never the frame — decides the folder, so a pane can only
|
|
66
|
+
* write next to a session it may already address.
|
|
67
|
+
*/
|
|
68
|
+
const fs = __importStar(require("fs"));
|
|
69
|
+
const os = __importStar(require("os"));
|
|
70
|
+
const path = __importStar(require("path"));
|
|
71
|
+
const crypto = __importStar(require("crypto"));
|
|
72
|
+
exports.ATTACHMENTS_DIR = path.join('.hiveop', 'attachments');
|
|
73
|
+
exports.MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
74
|
+
/** The image type, read from the bytes themselves — never from the name. */
|
|
75
|
+
function sniffImageExt(buf) {
|
|
76
|
+
if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
|
77
|
+
return 'png';
|
|
78
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
|
|
79
|
+
return 'jpg';
|
|
80
|
+
if (buf.length >= 6 && (buf.subarray(0, 6).toString('latin1') === 'GIF87a' || buf.subarray(0, 6).toString('latin1') === 'GIF89a'))
|
|
81
|
+
return 'gif';
|
|
82
|
+
if (buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP')
|
|
83
|
+
return 'webp';
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A filename stem that is safe on every filesystem AND needs no shell
|
|
88
|
+
* escaping in a prompt: letters, digits, dot, dash, underscore only; no
|
|
89
|
+
* leading dot; at most 40 chars; never empty.
|
|
90
|
+
*/
|
|
91
|
+
function safeAttachmentStem(name) {
|
|
92
|
+
const base = String(name ?? '').split(/[\\/]/).pop() ?? '';
|
|
93
|
+
const stem = base.replace(/\.[A-Za-z0-9]{1,5}$/, '');
|
|
94
|
+
const cleaned = stem
|
|
95
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
96
|
+
.replace(/-{2,}/g, '-')
|
|
97
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
98
|
+
.slice(0, 40)
|
|
99
|
+
.replace(/[-.]+$/g, '');
|
|
100
|
+
return cleaned || 'image';
|
|
101
|
+
}
|
|
102
|
+
function stamp(now) {
|
|
103
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
104
|
+
return `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
105
|
+
}
|
|
106
|
+
exports.realAttachmentDeps = {
|
|
107
|
+
mkdirp: (dir) => fs.mkdirSync(dir, { recursive: true }),
|
|
108
|
+
exists: (p) => fs.existsSync(p),
|
|
109
|
+
realpath: (p) => fs.realpathSync(p),
|
|
110
|
+
writeFileAtomic: (p, data) => {
|
|
111
|
+
const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
112
|
+
fs.writeFileSync(tmp, data);
|
|
113
|
+
fs.renameSync(tmp, p);
|
|
114
|
+
},
|
|
115
|
+
now: () => new Date(),
|
|
116
|
+
random: () => crypto.randomBytes(2).toString('hex'),
|
|
117
|
+
};
|
|
118
|
+
/** Write one image under `<root>/.hiveop/attachments/` and return its absolute path. */
|
|
119
|
+
function writeAttachment(input, deps = exports.realAttachmentDeps) {
|
|
120
|
+
const b64 = typeof input.dataBase64 === 'string' ? input.dataBase64 : '';
|
|
121
|
+
// Checked on the ENCODED length first, so an oversized frame is refused
|
|
122
|
+
// without allocating its decoded buffer.
|
|
123
|
+
if (b64.length > Math.ceil(exports.MAX_ATTACHMENT_BYTES / 3) * 4 + 4)
|
|
124
|
+
return { ok: false, reason: 'too-large' };
|
|
125
|
+
const data = Buffer.from(b64, 'base64');
|
|
126
|
+
if (data.length > exports.MAX_ATTACHMENT_BYTES)
|
|
127
|
+
return { ok: false, reason: 'too-large' };
|
|
128
|
+
const ext = sniffImageExt(data);
|
|
129
|
+
if (!ext)
|
|
130
|
+
return { ok: false, reason: 'not-image' };
|
|
131
|
+
try {
|
|
132
|
+
const root = deps.realpath(input.root);
|
|
133
|
+
const dir = path.join(root, exports.ATTACHMENTS_DIR);
|
|
134
|
+
deps.mkdirp(dir);
|
|
135
|
+
// A checkout's `.hiveop` could be a symlink planted by the repo — refuse
|
|
136
|
+
// to follow it anywhere outside the project.
|
|
137
|
+
const realDir = deps.realpath(dir);
|
|
138
|
+
if (realDir !== dir && !realDir.startsWith(root + path.sep))
|
|
139
|
+
return { ok: false, reason: 'error' };
|
|
140
|
+
const ignore = path.join(realDir, '.gitignore');
|
|
141
|
+
if (!deps.exists(ignore))
|
|
142
|
+
deps.writeFileAtomic(ignore, Buffer.from('*\n'));
|
|
143
|
+
const file = path.join(realDir, `${stamp(deps.now())}-${deps.random()}-${safeAttachmentStem(input.name)}.${ext}`);
|
|
144
|
+
deps.writeFileAtomic(file, data);
|
|
145
|
+
return { ok: true, path: file, bytes: data.length };
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { ok: false, reason: 'error' };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Where a session's attachments go: its project folder, else the bridge user's home. */
|
|
152
|
+
function attachmentRootFor(sessions, sessionId) {
|
|
153
|
+
if (typeof sessionId !== 'string' || !sessions.list().includes(sessionId))
|
|
154
|
+
return null;
|
|
155
|
+
return sessions.cwdOf(sessionId) ?? os.homedir();
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The whole `attach-file` frame → `file-attached` reply, shared verbatim by
|
|
159
|
+
* the local server and the relay client so the two lanes cannot drift.
|
|
160
|
+
*/
|
|
161
|
+
function handleAttachFrame(frame, sessions, deps = exports.realAttachmentDeps) {
|
|
162
|
+
const head = {
|
|
163
|
+
t: 'file-attached',
|
|
164
|
+
rid: frame.rid,
|
|
165
|
+
...(typeof frame.sessionId === 'string' ? { sessionId: frame.sessionId } : {}),
|
|
166
|
+
};
|
|
167
|
+
const root = attachmentRootFor(sessions, frame.sessionId);
|
|
168
|
+
if (!root)
|
|
169
|
+
return { ...head, ok: false, reason: 'no-session' };
|
|
170
|
+
const result = writeAttachment({ root, name: typeof frame.name === 'string' ? frame.name : 'image', dataBase64: typeof frame.data === 'string' ? frame.data : '' }, deps);
|
|
171
|
+
return result.ok ? { ...head, ok: true, path: result.path, bytes: result.bytes } : { ...head, ok: false, reason: result.reason };
|
|
172
|
+
}
|
|
@@ -110,6 +110,7 @@ const diskPreflight_1 = require("./diskPreflight");
|
|
|
110
110
|
// hcs-05's checkoutReclaim executor for the actual delete. See
|
|
111
111
|
// checkoutParkAndRemove.ts's doc comment for the full safety design.
|
|
112
112
|
const checkoutParkAndRemove_1 = require("./checkoutParkAndRemove");
|
|
113
|
+
const attachmentWriter_1 = require("./attachmentWriter");
|
|
113
114
|
const PROTOCOL_VERSION = 1;
|
|
114
115
|
/**
|
|
115
116
|
* fcct-27 — typed refusal when `resolveLaunch` names a binary this machine does
|
|
@@ -133,6 +134,8 @@ exports.SESSION_NOT_RELAY_OWNED = 'SESSION_NOT_RELAY_OWNED';
|
|
|
133
134
|
exports.SESSION_ADDRESSED_FRAMES = new Set([
|
|
134
135
|
'attach', 'input', 'peek', 'resize', 'detach', 'close', 'read-handoff',
|
|
135
136
|
'browser-input', 'browser-frame-ack', 'browser-resize', 'browser-sleep',
|
|
137
|
+
// Terminal attach writes next to a session — same consent boundary.
|
|
138
|
+
'attach-file',
|
|
136
139
|
]);
|
|
137
140
|
/**
|
|
138
141
|
* pucc-14 — assemble the per-session PTY env for a workspace-scoped `open`.
|
|
@@ -1501,6 +1504,10 @@ function startRelayClient(opts) {
|
|
|
1501
1504
|
});
|
|
1502
1505
|
break;
|
|
1503
1506
|
}
|
|
1507
|
+
case 'attach-file':
|
|
1508
|
+
// Terminal attach — shared verbatim with the local server.
|
|
1509
|
+
send(socket, (0, attachmentWriter_1.handleAttachFrame)(frame, opts.sessions));
|
|
1510
|
+
break;
|
|
1504
1511
|
case 'read-transcript': {
|
|
1505
1512
|
// wte-10 (docs/prd/work-this-epic.md D8): read ONE Claude Code
|
|
1506
1513
|
// agent's session transcript, live. Server-initiated (no pane,
|
package/dist/bridge/server.js
CHANGED
|
@@ -62,6 +62,7 @@ const sessionAnchor_1 = require("./sessionAnchor");
|
|
|
62
62
|
// tchp-30 — per-session own-touch tracker (see its doc comment). Fed from
|
|
63
63
|
// the SAME activity records fcct-24's tail already parses below.
|
|
64
64
|
const sessionTouchTracker_1 = require("./sessionTouchTracker");
|
|
65
|
+
const attachmentWriter_1 = require("./attachmentWriter");
|
|
65
66
|
const PROTOCOL_VERSION = 1;
|
|
66
67
|
function send(ws, frame) {
|
|
67
68
|
if (ws.readyState === ws_1.WebSocket.OPEN)
|
|
@@ -389,6 +390,8 @@ function startBridgeServer(opts) {
|
|
|
389
390
|
// of failing at save time.
|
|
390
391
|
capabilities: [
|
|
391
392
|
...(opts.writeLocalFile ? ['local-file-write'] : []),
|
|
393
|
+
// Terminal attach — this bridge answers `attach-file` frames.
|
|
394
|
+
'terminal-attach',
|
|
392
395
|
],
|
|
393
396
|
});
|
|
394
397
|
return;
|
|
@@ -930,6 +933,11 @@ function startBridgeServer(opts) {
|
|
|
930
933
|
});
|
|
931
934
|
break;
|
|
932
935
|
}
|
|
936
|
+
case 'attach-file':
|
|
937
|
+
// Terminal attach — a FILE write next to the session, never PTY
|
|
938
|
+
// input. Shared handler with relayClient.ts (both lanes).
|
|
939
|
+
send(ws, (0, attachmentWriter_1.handleAttachFrame)(frame, opts.sessions));
|
|
940
|
+
break;
|
|
933
941
|
default:
|
|
934
942
|
send(ws, { t: 'error', message: 'unknown frame type' });
|
|
935
943
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenrwa/marketplace-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "CLI tool for the Wenrwa Agent Marketplace \u2014 monitor bounties, agents, workspaces, and treasury from your terminal",
|
|
5
5
|
"author": "Wenrwa <dev@wenrwa.com>",
|
|
6
6
|
"license": "MIT",
|