@hydra-acp/archiver 0.1.16 → 0.1.18
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 +26 -9
- package/dist/acp/attach.js +1 -201
- package/dist/acp/protocol.js +1 -13
- package/dist/archive-loop.js +1 -134
- package/dist/backend/encrypted.js +1 -59
- package/dist/backend/factory.js +1 -28
- package/dist/backend/fs.js +1 -78
- package/dist/backend/google-drive.js +1 -182
- package/dist/backend/s3.js +5 -189
- package/dist/backend/types.js +0 -2
- package/dist/bridge.js +1 -100
- package/dist/cold-sweep.js +1 -54
- package/dist/config.js +1 -159
- package/dist/daemon.js +1 -81
- package/dist/discovery.js +1 -82
- package/dist/envelope.js +1 -123
- package/dist/index.js +15 -241
- package/dist/keygen.js +8 -26
- package/dist/oauth/google.js +6 -195
- package/dist/pull-loop.js +1 -147
- package/dist/rule.js +1 -37
- package/dist/setup/conf-writer.js +4 -85
- package/dist/setup/downloads-scan.js +1 -44
- package/dist/setup/prompts.js +14 -123
- package/dist/setup/wizard.js +17 -415
- package/dist/state.js +1 -129
- package/dist/util/aws-credentials.js +1 -82
- package/dist/util/log.js +2 -46
- package/package.json +5 -4
- package/dist/acp/attach.js.map +0 -1
- package/dist/acp/protocol.js.map +0 -1
- package/dist/archive-loop.js.map +0 -1
- package/dist/backend/encrypted.js.map +0 -1
- package/dist/backend/factory.js.map +0 -1
- package/dist/backend/fs.js.map +0 -1
- package/dist/backend/google-drive.js.map +0 -1
- package/dist/backend/s3.js.map +0 -1
- package/dist/backend/types.js.map +0 -1
- package/dist/bridge.js.map +0 -1
- package/dist/cold-sweep.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/daemon.js.map +0 -1
- package/dist/discovery.js.map +0 -1
- package/dist/envelope.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/keygen.js.map +0 -1
- package/dist/oauth/google.js.map +0 -1
- package/dist/pull-loop.js.map +0 -1
- package/dist/rule.js.map +0 -1
- package/dist/setup/conf-writer.js.map +0 -1
- package/dist/setup/downloads-scan.js.map +0 -1
- package/dist/setup/prompts.js.map +0 -1
- package/dist/setup/wizard.js.map +0 -1
- package/dist/state.js.map +0 -1
- package/dist/util/aws-credentials.js.map +0 -1
- package/dist/util/log.js.map +0 -1
package/README.md
CHANGED
|
@@ -20,7 +20,24 @@ Conflict resolution is last-writer-wins by envelope `uploadedAt`. The envelope f
|
|
|
20
20
|
From npm (recommended once published):
|
|
21
21
|
|
|
22
22
|
```sh
|
|
23
|
-
npm install -g @hydra-acp/archiver
|
|
23
|
+
npm install -g @hydra-acp/cli @hydra-acp/archiver
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This drops the `hydra-acp` (and `hydra`) CLI plus a `hydra-acp-archiver` binary on your PATH. The CLI dispatches `hydra-acp <name>` to any `hydra-acp-<name>` binary on PATH, so the archiver is also reachable as `hydra-acp archiver`.
|
|
27
|
+
|
|
28
|
+
Or from source:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
git clone git@github.com:smagnuso/hydra-acp-archiver.git ~/dev/hydra-acp-archiver
|
|
32
|
+
cd ~/dev/hydra-acp-archiver
|
|
33
|
+
npm install
|
|
34
|
+
npm run build
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Setup
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
hydra-acp archiver setup
|
|
24
41
|
```
|
|
25
42
|
|
|
26
43
|
Or from source:
|
|
@@ -40,7 +57,7 @@ hydra-acp-archiver setup
|
|
|
40
57
|
|
|
41
58
|
The wizard walks you through picking a backend (Google Drive / S3 / Filesystem), configuring credentials, optionally generating an AES-256-GCM key for encryption, writing `~/.hydra-acp/archiver.conf`, and registering the archiver as a hydra extension. About 1 minute for S3/Filesystem; about 5–8 minutes for Google Drive (the GCP Console click-through is the long pole).
|
|
42
59
|
|
|
43
|
-
Re-run `hydra-acp
|
|
60
|
+
Re-run `hydra-acp archiver setup` any time to switch backends or rotate keys — it preserves existing custom config keys.
|
|
44
61
|
|
|
45
62
|
<details>
|
|
46
63
|
<summary>Manual Google Drive setup (if you prefer not to run the wizard)</summary>
|
|
@@ -54,7 +71,7 @@ You provide your own OAuth client (Google's terms make it impractical to ship a
|
|
|
54
71
|
3. Configure the **OAuth consent screen**. User type: **External**. Add your Google account under **Test Users**.
|
|
55
72
|
4. **Credentials → Create credentials → OAuth client ID**. Application type: **Desktop app**.
|
|
56
73
|
5. Download the resulting JSON and save it to `~/.hydra-acp/archiver-google-credentials.json` (or anywhere, and set `HYDRA_ACP_ARCHIVER_GOOGLE_CREDENTIALS`).
|
|
57
|
-
6. Run `hydra-acp
|
|
74
|
+
6. Run `hydra-acp archiver gdrive login`. Your browser opens to Google's consent screen. The "Google hasn't verified this app" interstitial is expected for an unverified personal-use client — click **Advanced → Go to (unsafe)** and approve. The redirect lands on a transient local server, the archiver writes `~/.hydra-acp/archiver-google-token.json` (mode 0600), and you're done.
|
|
58
75
|
7. Register: `hydra-acp extensions add hydra-acp-archiver`.
|
|
59
76
|
|
|
60
77
|
After this, restart the daemon. The archiver process starts up, creates a `hydra-acp-archive/` folder in your Drive on first upload, and begins syncing.
|
|
@@ -84,7 +101,7 @@ That writes the equivalent entry into `~/.hydra-acp/config.json`. On `hydra-acp
|
|
|
84
101
|
|
|
85
102
|
## Multi-machine setup
|
|
86
103
|
|
|
87
|
-
Run `hydra-acp
|
|
104
|
+
Run `hydra-acp archiver setup` on each machine that should sync. For Google Drive, log in with the **same Google account** and use the **same Drive folder name** so each machine points at the shared archive. For S3, point each machine at the same bucket. For filesystem, point each at a directory that some external sync tool (Syncthing, Dropbox, iCloud) mirrors.
|
|
88
105
|
|
|
89
106
|
If you turned on encryption, copy `~/.hydra-acp/archiver-key` from your first machine to each peer; the wizard's fingerprint output lets you verify they match.
|
|
90
107
|
|
|
@@ -152,7 +169,7 @@ All three backends support optional AES-256-GCM encryption. When enabled, blobs
|
|
|
152
169
|
Generate a key on one machine:
|
|
153
170
|
|
|
154
171
|
```sh
|
|
155
|
-
hydra-acp
|
|
172
|
+
hydra-acp archiver keygen
|
|
156
173
|
```
|
|
157
174
|
|
|
158
175
|
This writes a 32-byte key as a hex file (mode 0600) and prints:
|
|
@@ -207,7 +224,7 @@ Set `HYDRA_ACP_ARCHIVER_HOST_ID` to override the default (`os.hostname()` saniti
|
|
|
207
224
|
|
|
208
225
|
### Key rotation and re-upload
|
|
209
226
|
|
|
210
|
-
When encryption is enabled and you regenerate the key (`hydra-acp
|
|
227
|
+
When encryption is enabled and you regenerate the key (`hydra-acp archiver keygen`), the fingerprint prefix changes. The new prefix namespace is empty, so the cold sweep on the next daemon start re-uploads all sessions encrypted with the new key. Old blobs under the previous prefix are simply ignored — no decryption errors, no manual cleanup required (though you can delete the old prefix from the bucket/dir when convenient).
|
|
211
228
|
|
|
212
229
|
## Configuration file
|
|
213
230
|
|
|
@@ -296,13 +313,13 @@ Return `false` to skip an upload. Any other value (including `undefined`) archiv
|
|
|
296
313
|
|
|
297
314
|
- `~/.hydra-acp/archiver-state.json` — per-lineage cache of last uploaded hash + last seen remote upload, used for self-loop suppression. Safe to delete; archiver will rebuild it.
|
|
298
315
|
- `~/.hydra-acp/archiver-google-credentials.json` — OAuth client JSON you downloaded.
|
|
299
|
-
- `~/.hydra-acp/archiver-google-token.json` — refresh + access token (mode 0600). Re-run `hydra-acp
|
|
300
|
-
- `~/.hydra-acp/archiver-key` — encryption key (mode 0600), written by `hydra-acp
|
|
316
|
+
- `~/.hydra-acp/archiver-google-token.json` — refresh + access token (mode 0600). Re-run `hydra-acp archiver login` to refresh.
|
|
317
|
+
- `~/.hydra-acp/archiver-key` — encryption key (mode 0600), written by `hydra-acp archiver keygen`. Copy this file to every machine in your sync group. Keep it safe — losing it means losing access to encrypted blobs.
|
|
301
318
|
|
|
302
319
|
## Troubleshooting
|
|
303
320
|
|
|
304
321
|
- **`Missing HYDRA_ACP_TOKEN env var`** — you ran the archiver directly instead of via the daemon. Run it as a registered extension.
|
|
305
|
-
- **`No Google OAuth token at …`** — run `hydra-acp
|
|
322
|
+
- **`No Google OAuth token at …`** — run `hydra-acp archiver gdrive login` first.
|
|
306
323
|
- **`OAuth credentials file not found`** — follow the **First-time Google setup** steps to download the client JSON from GCP Console.
|
|
307
324
|
- **Files aren't appearing in Drive** — check `~/.hydra-acp/extensions/hydra-acp-archiver.log` for errors. Common: consent-screen test-user list doesn't include your Google account.
|
|
308
325
|
- **Two machines kept overwriting each other** — that's last-writer-wins working as designed if both are actively editing the same session. Avoid editing the same session on two machines simultaneously; one of them will lose its diff. A future `activeOn` claim-lock will close this gap.
|
package/dist/acp/attach.js
CHANGED
|
@@ -1,201 +1 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { WebSocket } from "ws";
|
|
4
|
-
import { logger } from "../util/log.js";
|
|
5
|
-
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
6
|
-
import { ACP_PROTOCOL_VERSION, isNotification, isRequest, isResponse, } from "./protocol.js";
|
|
7
|
-
const log = logger("acp");
|
|
8
|
-
export class AcpAttach extends EventEmitter {
|
|
9
|
-
opts;
|
|
10
|
-
ws;
|
|
11
|
-
nextId = 1;
|
|
12
|
-
pending = new Map();
|
|
13
|
-
connected = false;
|
|
14
|
-
constructor(opts) {
|
|
15
|
-
super();
|
|
16
|
-
this.opts = opts;
|
|
17
|
-
}
|
|
18
|
-
get sessionId() {
|
|
19
|
-
return this.opts.sessionId;
|
|
20
|
-
}
|
|
21
|
-
get isConnected() {
|
|
22
|
-
return this.connected;
|
|
23
|
-
}
|
|
24
|
-
start() {
|
|
25
|
-
log.debug(`connecting ${this.opts.daemonWsUrl} for ${this.opts.sessionId}`);
|
|
26
|
-
const subprotocols = ["acp.v1", `hydra-acp-token.${this.opts.token}`];
|
|
27
|
-
let ws;
|
|
28
|
-
try {
|
|
29
|
-
ws = new WebSocket(this.opts.daemonWsUrl, subprotocols);
|
|
30
|
-
}
|
|
31
|
-
catch (err) {
|
|
32
|
-
this.emit("error", err);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
this.ws = ws;
|
|
36
|
-
ws.on("open", () => {
|
|
37
|
-
this.connected = true;
|
|
38
|
-
log.info(`ws open ${this.opts.sessionId}`);
|
|
39
|
-
void this.handshake()
|
|
40
|
-
.then(() => {
|
|
41
|
-
this.emit("open");
|
|
42
|
-
})
|
|
43
|
-
.catch((err) => {
|
|
44
|
-
this.emit("error", err);
|
|
45
|
-
try {
|
|
46
|
-
this.ws?.close();
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
void 0;
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
ws.on("message", (data, isBinary) => {
|
|
54
|
-
if (isBinary) {
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
const text = data.toString("utf8");
|
|
58
|
-
try {
|
|
59
|
-
const parsed = JSON.parse(text);
|
|
60
|
-
this.onMessage(parsed);
|
|
61
|
-
}
|
|
62
|
-
catch (err) {
|
|
63
|
-
log.warn(`parse error on ${this.opts.sessionId}: ${err.message}; raw=${text.slice(0, 200)}`);
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
ws.on("error", (err) => {
|
|
67
|
-
log.warn(`ws error ${this.opts.sessionId}: ${err.message}`);
|
|
68
|
-
this.emit("error", err);
|
|
69
|
-
});
|
|
70
|
-
ws.on("close", (code, reason) => {
|
|
71
|
-
const hadError = code >= 4000 || code === 1006 || code === 1011;
|
|
72
|
-
const reasonText = reason.toString("utf8");
|
|
73
|
-
this.connected = false;
|
|
74
|
-
log.info(`ws closed ${this.opts.sessionId} code=${code}${reasonText ? ` reason=${reasonText}` : ""}`);
|
|
75
|
-
for (const [, p] of this.pending) {
|
|
76
|
-
p.reject(new Error("ws closed"));
|
|
77
|
-
}
|
|
78
|
-
this.pending.clear();
|
|
79
|
-
this.emit("close", { hadError });
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
stop() {
|
|
83
|
-
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
|
|
84
|
-
try {
|
|
85
|
-
this.ws.close();
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
void 0;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
async request(method, params) {
|
|
93
|
-
const id = this.nextId++;
|
|
94
|
-
const msg = {
|
|
95
|
-
jsonrpc: "2.0",
|
|
96
|
-
id,
|
|
97
|
-
method,
|
|
98
|
-
...(params !== undefined ? { params } : {}),
|
|
99
|
-
};
|
|
100
|
-
this.write(msg);
|
|
101
|
-
return new Promise((resolve, reject) => {
|
|
102
|
-
this.pending.set(id, {
|
|
103
|
-
resolve: (resp) => {
|
|
104
|
-
if (resp.error) {
|
|
105
|
-
reject(new Error(`${resp.error.code}: ${resp.error.message}`));
|
|
106
|
-
}
|
|
107
|
-
else {
|
|
108
|
-
resolve(resp.result);
|
|
109
|
-
}
|
|
110
|
-
},
|
|
111
|
-
reject,
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
notify(method, params) {
|
|
116
|
-
const msg = {
|
|
117
|
-
jsonrpc: "2.0",
|
|
118
|
-
method,
|
|
119
|
-
...(params !== undefined ? { params } : {}),
|
|
120
|
-
};
|
|
121
|
-
this.write(msg);
|
|
122
|
-
}
|
|
123
|
-
reply(id, result) {
|
|
124
|
-
const msg = { jsonrpc: "2.0", id, result };
|
|
125
|
-
this.write(msg);
|
|
126
|
-
}
|
|
127
|
-
replyError(id, code, message) {
|
|
128
|
-
const msg = {
|
|
129
|
-
jsonrpc: "2.0",
|
|
130
|
-
id,
|
|
131
|
-
error: { code, message },
|
|
132
|
-
};
|
|
133
|
-
this.write(msg);
|
|
134
|
-
}
|
|
135
|
-
async handshake() {
|
|
136
|
-
try {
|
|
137
|
-
await this.request("initialize", {
|
|
138
|
-
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
139
|
-
clientCapabilities: {
|
|
140
|
-
fs: { readTextFile: false, writeTextFile: false },
|
|
141
|
-
terminal: false,
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
catch (err) {
|
|
146
|
-
log.warn(`initialize failed for ${this.opts.sessionId}: ${err.message}`);
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
// historyPolicy: "none" — the archiver pulls full bundles via REST,
|
|
150
|
-
// it doesn't need history replayed over the WS attach.
|
|
151
|
-
//
|
|
152
|
-
// readonly: true — the archiver is a passive observer. On a live
|
|
153
|
-
// session this still binds as a real attached client and streams
|
|
154
|
-
// session/update. On a cold session it takes the daemon's viewer
|
|
155
|
-
// path instead of resurrecting an agent, so re-attaching after an
|
|
156
|
-
// idle close can't keep a quiet session on a resurrection treadmill.
|
|
157
|
-
const attachResult = await this.request("session/attach", {
|
|
158
|
-
sessionId: this.opts.sessionId,
|
|
159
|
-
historyPolicy: "none",
|
|
160
|
-
// readonly is a hydra extension → rides under _meta["hydra-acp"]
|
|
161
|
-
// (session/attach keeps only RFD #533 fields at the top level).
|
|
162
|
-
_meta: { "hydra-acp": { readonly: true } },
|
|
163
|
-
clientInfo: { name: "hydra-acp-archiver", version: pkg.version },
|
|
164
|
-
});
|
|
165
|
-
log.info(`attached ${this.opts.sessionId}${attachResult.replayed !== undefined
|
|
166
|
-
? ` replayed=${attachResult.replayed}`
|
|
167
|
-
: ""}`);
|
|
168
|
-
}
|
|
169
|
-
catch (err) {
|
|
170
|
-
log.warn(`session/attach failed for ${this.opts.sessionId}: ${err.message}`);
|
|
171
|
-
throw err;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
write(msg) {
|
|
175
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
176
|
-
log.warn(`drop write to closed ws: ${JSON.stringify(msg)}`);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
this.ws.send(JSON.stringify(msg));
|
|
180
|
-
}
|
|
181
|
-
onMessage(m) {
|
|
182
|
-
if (isResponse(m)) {
|
|
183
|
-
const p = this.pending.get(m.id);
|
|
184
|
-
if (p) {
|
|
185
|
-
this.pending.delete(m.id);
|
|
186
|
-
p.resolve(m);
|
|
187
|
-
}
|
|
188
|
-
else {
|
|
189
|
-
log.debug(`unmatched response id=${String(m.id)}`);
|
|
190
|
-
}
|
|
191
|
-
this.emit("response", m);
|
|
192
|
-
}
|
|
193
|
-
else if (isRequest(m)) {
|
|
194
|
-
this.emit("request", m);
|
|
195
|
-
}
|
|
196
|
-
else if (isNotification(m)) {
|
|
197
|
-
this.emit("notification", m);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
//# sourceMappingURL=attach.js.map
|
|
1
|
+
import{EventEmitter as p}from"node:events";import{readFileSync as d}from"node:fs";import{WebSocket as c}from"ws";import{logger as h}from"../util/log.js";const l=JSON.parse(d(new URL("../../package.json",import.meta.url),"utf8"));import{ACP_PROTOCOL_VERSION as f,isNotification as g,isRequest as u,isResponse as w}from"./protocol.js";const o=h("acp");class E extends p{constructor(e){super();this.opts=e}opts;ws;nextId=1;pending=new Map;connected=!1;get sessionId(){return this.opts.sessionId}get isConnected(){return this.connected}start(){o.debug(`connecting ${this.opts.daemonWsUrl} for ${this.opts.sessionId}`);const e=["acp.v1",`hydra-acp-token.${this.opts.token}`];let t;try{t=new c(this.opts.daemonWsUrl,e)}catch(s){this.emit("error",s);return}this.ws=t,t.on("open",()=>{this.connected=!0,o.info(`ws open ${this.opts.sessionId}`),this.handshake().then(()=>{this.emit("open")}).catch(s=>{this.emit("error",s);try{this.ws?.close()}catch{}})}),t.on("message",(s,r)=>{if(r)return;const i=s.toString("utf8");try{const n=JSON.parse(i);this.onMessage(n)}catch(n){o.warn(`parse error on ${this.opts.sessionId}: ${n.message}; raw=${i.slice(0,200)}`)}}),t.on("error",s=>{o.warn(`ws error ${this.opts.sessionId}: ${s.message}`),this.emit("error",s)}),t.on("close",(s,r)=>{const i=s>=4e3||s===1006||s===1011,n=r.toString("utf8");this.connected=!1,o.info(`ws closed ${this.opts.sessionId} code=${s}${n?` reason=${n}`:""}`);for(const[,a]of this.pending)a.reject(new Error("ws closed"));this.pending.clear(),this.emit("close",{hadError:i})})}stop(){if(this.ws&&this.ws.readyState!==c.CLOSED)try{this.ws.close()}catch{}}async request(e,t){const s=this.nextId++,r={jsonrpc:"2.0",id:s,method:e,...t!==void 0?{params:t}:{}};return this.write(r),new Promise((i,n)=>{this.pending.set(s,{resolve:a=>{a.error?n(new Error(`${a.error.code}: ${a.error.message}`)):i(a.result)},reject:n})})}notify(e,t){const s={jsonrpc:"2.0",method:e,...t!==void 0?{params:t}:{}};this.write(s)}reply(e,t){const s={jsonrpc:"2.0",id:e,result:t};this.write(s)}replyError(e,t,s){const r={jsonrpc:"2.0",id:e,error:{code:t,message:s}};this.write(r)}async handshake(){try{await this.request("initialize",{protocolVersion:f,clientCapabilities:{fs:{readTextFile:!1,writeTextFile:!1},terminal:!1}})}catch(e){o.warn(`initialize failed for ${this.opts.sessionId}: ${e.message}`)}try{const e=await this.request("session/attach",{sessionId:this.opts.sessionId,historyPolicy:"none",_meta:{"hydra-acp":{readonly:!0}},clientInfo:{name:"hydra-acp-archiver",version:l.version}});o.info(`attached ${this.opts.sessionId}${e.replayed!==void 0?` replayed=${e.replayed}`:""}`)}catch(e){throw o.warn(`session/attach failed for ${this.opts.sessionId}: ${e.message}`),e}}write(e){if(!this.ws||this.ws.readyState!==c.OPEN){o.warn(`drop write to closed ws: ${JSON.stringify(e)}`);return}this.ws.send(JSON.stringify(e))}onMessage(e){if(w(e)){const t=this.pending.get(e.id);t?(this.pending.delete(e.id),t.resolve(e)):o.debug(`unmatched response id=${String(e.id)}`),this.emit("response",e)}else u(e)?this.emit("request",e):g(e)&&this.emit("notification",e)}}export{E as AcpAttach};
|
package/dist/acp/protocol.js
CHANGED
|
@@ -1,13 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// truth for the initialize handshake; never a literal at the callsite.
|
|
3
|
-
export const ACP_PROTOCOL_VERSION = 1;
|
|
4
|
-
export function isRequest(m) {
|
|
5
|
-
return "method" in m && "id" in m;
|
|
6
|
-
}
|
|
7
|
-
export function isNotification(m) {
|
|
8
|
-
return "method" in m && !("id" in m);
|
|
9
|
-
}
|
|
10
|
-
export function isResponse(m) {
|
|
11
|
-
return !("method" in m) && "id" in m;
|
|
12
|
-
}
|
|
13
|
-
//# sourceMappingURL=protocol.js.map
|
|
1
|
+
const e=1;function o(n){return"method"in n&&"id"in n}function s(n){return"method"in n&&!("id"in n)}function t(n){return!("method"in n)&&"id"in n}export{e as ACP_PROTOCOL_VERSION,s as isNotification,o as isRequest,t as isResponse};
|
package/dist/archive-loop.js
CHANGED
|
@@ -1,134 +1 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { hashBundle, keyFor, serialize, wrap, } from "./envelope.js";
|
|
3
|
-
import { logger } from "./util/log.js";
|
|
4
|
-
const log = logger("archive");
|
|
5
|
-
export class ArchiveLoop {
|
|
6
|
-
opts;
|
|
7
|
-
host;
|
|
8
|
-
pending = new Map();
|
|
9
|
-
// Tracks sessionId -> meta we know about (just cwd/title/agentId for
|
|
10
|
-
// now), so the rule fn has something to decide on.
|
|
11
|
-
meta = new Map();
|
|
12
|
-
stopped = false;
|
|
13
|
-
constructor(opts) {
|
|
14
|
-
this.opts = opts;
|
|
15
|
-
this.host = opts.host ?? defaultHost();
|
|
16
|
-
}
|
|
17
|
-
setMeta(sessionId, meta) {
|
|
18
|
-
this.meta.set(sessionId, meta);
|
|
19
|
-
}
|
|
20
|
-
forgetSession(sessionId) {
|
|
21
|
-
const p = this.pending.get(sessionId);
|
|
22
|
-
if (p) {
|
|
23
|
-
clearTimeout(p.timer);
|
|
24
|
-
this.pending.delete(sessionId);
|
|
25
|
-
}
|
|
26
|
-
this.meta.delete(sessionId);
|
|
27
|
-
}
|
|
28
|
-
// Cancel any pending debounced upload and synchronously run one final
|
|
29
|
-
// flush — used when a session transitions live → cold so we capture
|
|
30
|
-
// its last state. Errors are logged, not thrown, so callers can
|
|
31
|
-
// fire-and-forget. The session's meta is dropped after the flush
|
|
32
|
-
// resolves.
|
|
33
|
-
async finalFlush(sessionId) {
|
|
34
|
-
const existing = this.pending.get(sessionId);
|
|
35
|
-
if (existing) {
|
|
36
|
-
clearTimeout(existing.timer);
|
|
37
|
-
this.pending.delete(sessionId);
|
|
38
|
-
}
|
|
39
|
-
try {
|
|
40
|
-
await this.flush(sessionId);
|
|
41
|
-
}
|
|
42
|
-
catch (err) {
|
|
43
|
-
log.warn(`final flush ${sessionId} failed: ${err.message}`);
|
|
44
|
-
}
|
|
45
|
-
this.meta.delete(sessionId);
|
|
46
|
-
}
|
|
47
|
-
markDirty(sessionId) {
|
|
48
|
-
if (this.stopped) {
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
const existing = this.pending.get(sessionId);
|
|
52
|
-
if (existing) {
|
|
53
|
-
clearTimeout(existing.timer);
|
|
54
|
-
}
|
|
55
|
-
const timer = setTimeout(() => {
|
|
56
|
-
this.pending.delete(sessionId);
|
|
57
|
-
void this.flush(sessionId).catch((err) => {
|
|
58
|
-
log.warn(`flush ${sessionId} failed: ${err.message}`);
|
|
59
|
-
});
|
|
60
|
-
}, this.opts.debounceMs);
|
|
61
|
-
timer.unref();
|
|
62
|
-
this.pending.set(sessionId, { timer });
|
|
63
|
-
}
|
|
64
|
-
stop() {
|
|
65
|
-
this.stopped = true;
|
|
66
|
-
for (const p of this.pending.values()) {
|
|
67
|
-
clearTimeout(p.timer);
|
|
68
|
-
}
|
|
69
|
-
this.pending.clear();
|
|
70
|
-
}
|
|
71
|
-
// flushNow is exposed for tests so they don't have to wait out the
|
|
72
|
-
// debounce window. Production code goes through markDirty.
|
|
73
|
-
async flushNow(sessionId) {
|
|
74
|
-
const existing = this.pending.get(sessionId);
|
|
75
|
-
if (existing) {
|
|
76
|
-
clearTimeout(existing.timer);
|
|
77
|
-
this.pending.delete(sessionId);
|
|
78
|
-
}
|
|
79
|
-
await this.flush(sessionId);
|
|
80
|
-
}
|
|
81
|
-
async flush(sessionId) {
|
|
82
|
-
const bundle = await this.opts.daemon.exportSession(sessionId);
|
|
83
|
-
const lineageId = bundle.session.lineageId;
|
|
84
|
-
if (!lineageId) {
|
|
85
|
-
log.warn(`session ${sessionId} export had no lineageId; skipping`);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
const meta = this.meta.get(sessionId);
|
|
89
|
-
const rule = this.opts.getRule();
|
|
90
|
-
const shouldArchive = await runRule(rule, {
|
|
91
|
-
sessionId,
|
|
92
|
-
lineageId,
|
|
93
|
-
meta: meta ?? {},
|
|
94
|
-
});
|
|
95
|
-
if (!shouldArchive) {
|
|
96
|
-
log.debug(`rule skipped session ${sessionId} (lineage ${lineageId})`);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
const newHash = hashBundle(bundle);
|
|
100
|
-
const prev = this.opts.state.get(lineageId);
|
|
101
|
-
if (prev.lastUploadedHash === newHash) {
|
|
102
|
-
log.debug(`unchanged content for lineage ${lineageId} — skip upload`);
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
const envelope = wrap(bundle, lineageId, this.host);
|
|
106
|
-
await this.opts.backend.put(keyFor(lineageId), serialize(envelope));
|
|
107
|
-
await this.opts.state.set(lineageId, {
|
|
108
|
-
lastUploadedHash: newHash,
|
|
109
|
-
lastUploadedAt: envelope.uploadedAt,
|
|
110
|
-
});
|
|
111
|
-
log.info(`uploaded lineage=${lineageId} session=${sessionId} hash=${newHash.slice(0, 22)}…`);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
async function runRule(rule, ev) {
|
|
115
|
-
try {
|
|
116
|
-
const r = await rule(ev);
|
|
117
|
-
return r !== false;
|
|
118
|
-
}
|
|
119
|
-
catch (err) {
|
|
120
|
-
log.warn(`rule threw for ${ev.sessionId}: ${err.message}; defaulting to archive`);
|
|
121
|
-
return true;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
function defaultHost() {
|
|
125
|
-
let user = "unknown";
|
|
126
|
-
try {
|
|
127
|
-
user = userInfo().username;
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
// happens in some sandboxes; fall through
|
|
131
|
-
}
|
|
132
|
-
return { host: hostname(), user };
|
|
133
|
-
}
|
|
134
|
-
//# sourceMappingURL=archive-loop.js.map
|
|
1
|
+
import{hostname as p,userInfo as h}from"node:os";import{hashBundle as c,keyFor as u,serialize as d,wrap as g}from"./envelope.js";import{logger as m}from"./util/log.js";const n=m("archive");class b{constructor(e){this.opts=e;this.host=e.host??v()}opts;host;pending=new Map;meta=new Map;stopped=!1;setMeta(e,t){this.meta.set(e,t)}forgetSession(e){const t=this.pending.get(e);t&&(clearTimeout(t.timer),this.pending.delete(e)),this.meta.delete(e)}async finalFlush(e){const t=this.pending.get(e);t&&(clearTimeout(t.timer),this.pending.delete(e));try{await this.flush(e)}catch(i){n.warn(`final flush ${e} failed: ${i.message}`)}this.meta.delete(e)}markDirty(e){if(this.stopped)return;const t=this.pending.get(e);t&&clearTimeout(t.timer);const i=setTimeout(()=>{this.pending.delete(e),this.flush(e).catch(r=>{n.warn(`flush ${e} failed: ${r.message}`)})},this.opts.debounceMs);i.unref(),this.pending.set(e,{timer:i})}stop(){this.stopped=!0;for(const e of this.pending.values())clearTimeout(e.timer);this.pending.clear()}async flushNow(e){const t=this.pending.get(e);t&&(clearTimeout(t.timer),this.pending.delete(e)),await this.flush(e)}async flush(e){const t=await this.opts.daemon.exportSession(e),i=t.session.lineageId;if(!i){n.warn(`session ${e} export had no lineageId; skipping`);return}const r=this.meta.get(e),l=this.opts.getRule();if(!await f(l,{sessionId:e,lineageId:i,meta:r??{}})){n.debug(`rule skipped session ${e} (lineage ${i})`);return}const s=c(t);if(this.opts.state.get(i).lastUploadedHash===s){n.debug(`unchanged content for lineage ${i} \u2014 skip upload`);return}const a=g(t,i,this.host);await this.opts.backend.put(u(i),d(a)),await this.opts.state.set(i,{lastUploadedHash:s,lastUploadedAt:a.uploadedAt}),n.info(`uploaded lineage=${i} session=${e} hash=${s.slice(0,22)}\u2026`)}}async function f(o,e){try{return await o(e)!==!1}catch(t){return n.warn(`rule threw for ${e.sessionId}: ${t.message}; defaulting to archive`),!0}}function v(){let o="unknown";try{o=h().username}catch{}return{host:p(),user:o}}export{b as ArchiveLoop};
|
|
@@ -1,59 +1 @@
|
|
|
1
|
-
import
|
|
2
|
-
const VERSION = 1;
|
|
3
|
-
const VERSION_LEN = 1;
|
|
4
|
-
const FINGERPRINT_LEN = 8;
|
|
5
|
-
const IV_LEN = 12;
|
|
6
|
-
const TAG_LEN = 16;
|
|
7
|
-
// Offset to the start of ciphertext within a blob.
|
|
8
|
-
const HEADER_LEN = VERSION_LEN + FINGERPRINT_LEN + IV_LEN;
|
|
9
|
-
export class EncryptedBackend {
|
|
10
|
-
inner;
|
|
11
|
-
key;
|
|
12
|
-
keyFingerprint;
|
|
13
|
-
constructor(inner, key) {
|
|
14
|
-
this.inner = inner;
|
|
15
|
-
this.key = key;
|
|
16
|
-
if (key.length !== 32)
|
|
17
|
-
throw new Error("encryption key must be 32 bytes");
|
|
18
|
-
this.keyFingerprint = createHash("sha256").update(key).digest().subarray(0, FINGERPRINT_LEN);
|
|
19
|
-
}
|
|
20
|
-
init() {
|
|
21
|
-
return this.inner.init();
|
|
22
|
-
}
|
|
23
|
-
list() {
|
|
24
|
-
return this.inner.list();
|
|
25
|
-
}
|
|
26
|
-
delete(key) {
|
|
27
|
-
return this.inner.delete(key);
|
|
28
|
-
}
|
|
29
|
-
async put(key, data) {
|
|
30
|
-
const iv = randomBytes(IV_LEN);
|
|
31
|
-
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
|
32
|
-
const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
33
|
-
const tag = cipher.getAuthTag();
|
|
34
|
-
await this.inner.put(key, Buffer.concat([Buffer.from([VERSION]), this.keyFingerprint, iv, ciphertext, tag]));
|
|
35
|
-
}
|
|
36
|
-
async get(key) {
|
|
37
|
-
const blob = await this.inner.get(key);
|
|
38
|
-
if (blob.length < HEADER_LEN + TAG_LEN)
|
|
39
|
-
throw new Error(`encrypted blob too short for key ${key}`);
|
|
40
|
-
const version = blob[0];
|
|
41
|
-
if (version !== VERSION)
|
|
42
|
-
throw new Error(`unsupported encryption version ${version} for key ${key}: upgrade the archiver`);
|
|
43
|
-
const fingerprint = blob.subarray(VERSION_LEN, VERSION_LEN + FINGERPRINT_LEN);
|
|
44
|
-
if (!fingerprint.equals(this.keyFingerprint))
|
|
45
|
-
throw new Error(`key mismatch: blob ${key} was encrypted with a different key`);
|
|
46
|
-
const iv = blob.subarray(VERSION_LEN + FINGERPRINT_LEN, HEADER_LEN);
|
|
47
|
-
const tag = blob.subarray(blob.length - TAG_LEN);
|
|
48
|
-
const ciphertext = blob.subarray(HEADER_LEN, blob.length - TAG_LEN);
|
|
49
|
-
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
|
50
|
-
decipher.setAuthTag(tag);
|
|
51
|
-
try {
|
|
52
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
throw new Error(`blob ${key} failed authentication — data may be corrupted`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
//# sourceMappingURL=encrypted.js.map
|
|
1
|
+
import{createCipheriv as d,createDecipheriv as g,createHash as l,randomBytes as m}from"node:crypto";const y=1,i=1,o=8,f=12,h=16,p=i+o+f;class B{constructor(r,e){this.inner=r;this.key=e;if(e.length!==32)throw new Error("encryption key must be 32 bytes");this.keyFingerprint=l("sha256").update(e).digest().subarray(0,o)}inner;key;keyFingerprint;init(){return this.inner.init()}list(){return this.inner.list()}delete(r){return this.inner.delete(r)}async put(r,e){const t=m(f),n=d("aes-256-gcm",this.key,t),s=Buffer.concat([n.update(e),n.final()]),a=n.getAuthTag();await this.inner.put(r,Buffer.concat([Buffer.from([y]),this.keyFingerprint,t,s,a]))}async get(r){const e=await this.inner.get(r);if(e.length<p+h)throw new Error(`encrypted blob too short for key ${r}`);const t=e[0];if(t!==y)throw new Error(`unsupported encryption version ${t} for key ${r}: upgrade the archiver`);if(!e.subarray(i,i+o).equals(this.keyFingerprint))throw new Error(`key mismatch: blob ${r} was encrypted with a different key`);const s=e.subarray(i+o,p),a=e.subarray(e.length-h),u=e.subarray(p,e.length-h),c=g("aes-256-gcm",this.key,s);c.setAuthTag(a);try{return Buffer.concat([c.update(u),c.final()])}catch{throw new Error(`blob ${r} failed authentication \u2014 data may be corrupted`)}}}export{B as EncryptedBackend};
|
package/dist/backend/factory.js
CHANGED
|
@@ -1,28 +1 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { GoogleDriveBackend } from "./google-drive.js";
|
|
3
|
-
import { S3Backend } from "./s3.js";
|
|
4
|
-
export function makeBackend(config) {
|
|
5
|
-
switch (config.backend) {
|
|
6
|
-
case "fs":
|
|
7
|
-
return new FsBackend({ dir: config.fsDir, prefix: config.prefix });
|
|
8
|
-
case "google-drive":
|
|
9
|
-
return new GoogleDriveBackend({
|
|
10
|
-
credentialsPath: config.credentialsPath,
|
|
11
|
-
tokenPath: config.tokenPath,
|
|
12
|
-
folderName: config.driveFolderName,
|
|
13
|
-
prefix: config.prefix,
|
|
14
|
-
});
|
|
15
|
-
case "s3":
|
|
16
|
-
return new S3Backend({
|
|
17
|
-
bucket: config.s3Bucket,
|
|
18
|
-
region: config.s3Region,
|
|
19
|
-
endpoint: config.s3Endpoint,
|
|
20
|
-
prefix: config.prefix,
|
|
21
|
-
});
|
|
22
|
-
default: {
|
|
23
|
-
const exhaustive = config.backend;
|
|
24
|
-
throw new Error(`unknown backend: ${String(exhaustive)}`);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
//# sourceMappingURL=factory.js.map
|
|
1
|
+
import{FsBackend as t}from"./fs.js";import{GoogleDriveBackend as n}from"./google-drive.js";import{S3Backend as a}from"./s3.js";function s(e){switch(e.backend){case"fs":return new t({dir:e.fsDir,prefix:e.prefix});case"google-drive":return new n({credentialsPath:e.credentialsPath,tokenPath:e.tokenPath,folderName:e.driveFolderName,prefix:e.prefix});case"s3":return new a({bucket:e.s3Bucket,region:e.s3Region,endpoint:e.s3Endpoint,prefix:e.prefix});default:{const r=e.backend;throw new Error(`unknown backend: ${String(r)}`)}}}export{s as makeBackend};
|
package/dist/backend/fs.js
CHANGED
|
@@ -1,78 +1 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { dirname, resolve } from "node:path";
|
|
3
|
-
import { logger } from "../util/log.js";
|
|
4
|
-
const log = logger("backend.fs");
|
|
5
|
-
// Filesystem-backed implementation. Useful for tests, for users who want
|
|
6
|
-
// to point a folder-sync tool (Syncthing, Dropbox) at the archive
|
|
7
|
-
// directory, and for development against a local daemon.
|
|
8
|
-
export class FsBackend {
|
|
9
|
-
opts;
|
|
10
|
-
root;
|
|
11
|
-
constructor(opts) {
|
|
12
|
-
this.opts = opts;
|
|
13
|
-
this.root = opts.prefix !== "" ? resolve(opts.dir, opts.prefix) : opts.dir;
|
|
14
|
-
}
|
|
15
|
-
async init() {
|
|
16
|
-
await mkdir(this.root, { recursive: true });
|
|
17
|
-
log.info(`fs backend ready at ${this.root}`);
|
|
18
|
-
}
|
|
19
|
-
async list() {
|
|
20
|
-
return this.walk(this.root, "");
|
|
21
|
-
}
|
|
22
|
-
async walk(dir, rel) {
|
|
23
|
-
let names;
|
|
24
|
-
try {
|
|
25
|
-
names = await readdir(dir);
|
|
26
|
-
}
|
|
27
|
-
catch (err) {
|
|
28
|
-
const e = err;
|
|
29
|
-
if (e.code === "ENOENT") {
|
|
30
|
-
return [];
|
|
31
|
-
}
|
|
32
|
-
throw err;
|
|
33
|
-
}
|
|
34
|
-
const out = [];
|
|
35
|
-
for (const name of names) {
|
|
36
|
-
if (name.startsWith(".") || name.endsWith(".tmp")) {
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
const full = resolve(dir, name);
|
|
40
|
-
const entryRel = rel !== "" ? `${rel}/${name}` : name;
|
|
41
|
-
try {
|
|
42
|
-
const s = await stat(full);
|
|
43
|
-
if (s.isDirectory()) {
|
|
44
|
-
out.push(...await this.walk(full, entryRel));
|
|
45
|
-
}
|
|
46
|
-
else if (s.isFile()) {
|
|
47
|
-
out.push({ key: entryRel, size: s.size, modifiedAt: s.mtime.toISOString() });
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
catch (err) {
|
|
51
|
-
log.debug(`stat ${full}: ${err.message}`);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return out;
|
|
55
|
-
}
|
|
56
|
-
async get(key) {
|
|
57
|
-
return readFile(resolve(this.root, key));
|
|
58
|
-
}
|
|
59
|
-
async put(key, data) {
|
|
60
|
-
const dest = resolve(this.root, key);
|
|
61
|
-
const tmp = `${dest}.tmp`;
|
|
62
|
-
await mkdir(dirname(dest), { recursive: true });
|
|
63
|
-
await writeFile(tmp, data);
|
|
64
|
-
await rename(tmp, dest);
|
|
65
|
-
}
|
|
66
|
-
async delete(key) {
|
|
67
|
-
try {
|
|
68
|
-
await unlink(resolve(this.root, key));
|
|
69
|
-
}
|
|
70
|
-
catch (err) {
|
|
71
|
-
const e = err;
|
|
72
|
-
if (e.code !== "ENOENT") {
|
|
73
|
-
throw err;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
//# sourceMappingURL=fs.js.map
|
|
1
|
+
import{mkdir as d,readFile as y,readdir as f,rename as p,stat as l,unlink as u,writeFile as h}from"node:fs/promises";import{dirname as k,resolve as o}from"node:path";import{logger as g}from"../util/log.js";const m=g("backend.fs");class v{constructor(t){this.opts=t;this.root=t.prefix!==""?o(t.dir,t.prefix):t.dir}opts;root;async init(){await d(this.root,{recursive:!0}),m.info(`fs backend ready at ${this.root}`)}async list(){return this.walk(this.root,"")}async walk(t,e){let i;try{i=await f(t)}catch(r){if(r.code==="ENOENT")return[];throw r}const n=[];for(const r of i){if(r.startsWith(".")||r.endsWith(".tmp"))continue;const a=o(t,r),c=e!==""?`${e}/${r}`:r;try{const s=await l(a);s.isDirectory()?n.push(...await this.walk(a,c)):s.isFile()&&n.push({key:c,size:s.size,modifiedAt:s.mtime.toISOString()})}catch(s){m.debug(`stat ${a}: ${s.message}`)}}return n}async get(t){return y(o(this.root,t))}async put(t,e){const i=o(this.root,t),n=`${i}.tmp`;await d(k(i),{recursive:!0}),await h(n,e),await p(n,i)}async delete(t){try{await u(o(this.root,t))}catch(e){if(e.code!=="ENOENT")throw e}}}export{v as FsBackend};
|