@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.
Files changed (56) hide show
  1. package/README.md +26 -9
  2. package/dist/acp/attach.js +1 -201
  3. package/dist/acp/protocol.js +1 -13
  4. package/dist/archive-loop.js +1 -134
  5. package/dist/backend/encrypted.js +1 -59
  6. package/dist/backend/factory.js +1 -28
  7. package/dist/backend/fs.js +1 -78
  8. package/dist/backend/google-drive.js +1 -182
  9. package/dist/backend/s3.js +5 -189
  10. package/dist/backend/types.js +0 -2
  11. package/dist/bridge.js +1 -100
  12. package/dist/cold-sweep.js +1 -54
  13. package/dist/config.js +1 -159
  14. package/dist/daemon.js +1 -81
  15. package/dist/discovery.js +1 -82
  16. package/dist/envelope.js +1 -123
  17. package/dist/index.js +15 -241
  18. package/dist/keygen.js +8 -26
  19. package/dist/oauth/google.js +6 -195
  20. package/dist/pull-loop.js +1 -147
  21. package/dist/rule.js +1 -37
  22. package/dist/setup/conf-writer.js +4 -85
  23. package/dist/setup/downloads-scan.js +1 -44
  24. package/dist/setup/prompts.js +14 -123
  25. package/dist/setup/wizard.js +17 -415
  26. package/dist/state.js +1 -129
  27. package/dist/util/aws-credentials.js +1 -82
  28. package/dist/util/log.js +2 -46
  29. package/package.json +5 -4
  30. package/dist/acp/attach.js.map +0 -1
  31. package/dist/acp/protocol.js.map +0 -1
  32. package/dist/archive-loop.js.map +0 -1
  33. package/dist/backend/encrypted.js.map +0 -1
  34. package/dist/backend/factory.js.map +0 -1
  35. package/dist/backend/fs.js.map +0 -1
  36. package/dist/backend/google-drive.js.map +0 -1
  37. package/dist/backend/s3.js.map +0 -1
  38. package/dist/backend/types.js.map +0 -1
  39. package/dist/bridge.js.map +0 -1
  40. package/dist/cold-sweep.js.map +0 -1
  41. package/dist/config.js.map +0 -1
  42. package/dist/daemon.js.map +0 -1
  43. package/dist/discovery.js.map +0 -1
  44. package/dist/envelope.js.map +0 -1
  45. package/dist/index.js.map +0 -1
  46. package/dist/keygen.js.map +0 -1
  47. package/dist/oauth/google.js.map +0 -1
  48. package/dist/pull-loop.js.map +0 -1
  49. package/dist/rule.js.map +0 -1
  50. package/dist/setup/conf-writer.js.map +0 -1
  51. package/dist/setup/downloads-scan.js.map +0 -1
  52. package/dist/setup/prompts.js.map +0 -1
  53. package/dist/setup/wizard.js.map +0 -1
  54. package/dist/state.js.map +0 -1
  55. package/dist/util/aws-credentials.js.map +0 -1
  56. 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-archiver setup` any time to switch backends or rotate keys — it preserves existing custom config keys.
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-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.
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-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.
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-archiver keygen
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-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).
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-archiver login` to refresh.
300
- - `~/.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.
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-archiver gdrive login` first.
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.
@@ -1,201 +1 @@
1
- import { EventEmitter } from "node:events";
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};
@@ -1,13 +1 @@
1
- // ACP wire protocol version this extension speaks. Single source of
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};
@@ -1,134 +1 @@
1
- import { hostname, userInfo } from "node:os";
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 { createCipheriv, createDecipheriv, createHash, randomBytes, } from "node:crypto";
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};
@@ -1,28 +1 @@
1
- import { FsBackend } from "./fs.js";
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};
@@ -1,78 +1 @@
1
- import { mkdir, readFile, readdir, rename, stat, unlink, writeFile, } from "node:fs/promises";
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};