@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
@@ -1,182 +1 @@
1
- import { Readable } from "node:stream";
2
- import { google } from "googleapis";
3
- import { loadGoogleAuth } from "../oauth/google.js";
4
- import { logger } from "../util/log.js";
5
- const log = logger("backend.google-drive");
6
- const FOLDER_MIME = "application/vnd.google-apps.folder";
7
- const FILE_MIME = "application/json";
8
- // Drive-backed implementation. Uses the drive.file scope, which limits
9
- // visibility to files the app created plus those explicitly handed to
10
- // it — so the archiver never sees the user's other Drive contents.
11
- //
12
- // The prefix is resolved into real Drive subfolders on init(). A prefix
13
- // of "a1b2c3d4/alice-macbook/" creates:
14
- // hydra-acp-archive/
15
- // a1b2c3d4/
16
- // alice-macbook/
17
- // <lineageId>.hydra.archive
18
- //
19
- // list() recurses into all immediate subfolders of the prefix folder so the
20
- // sync backend (prefix = "user/") can see files from all host subfolders.
21
- export class GoogleDriveBackend {
22
- opts;
23
- drive;
24
- // The Drive folder ID corresponding to the resolved prefix path.
25
- prefixFolderId;
26
- constructor(opts) {
27
- this.opts = opts;
28
- }
29
- async init() {
30
- const auth = await loadGoogleAuth({
31
- credentialsPath: this.opts.credentialsPath,
32
- tokenPath: this.opts.tokenPath,
33
- });
34
- this.drive = google.drive({ version: "v3", auth });
35
- // Walk the root folder name + each prefix segment, ensuring each exists.
36
- let currentId = await this.ensureFolder(this.opts.folderName, undefined);
37
- const segments = this.opts.prefix
38
- .split("/")
39
- .filter((s) => s !== "");
40
- for (const seg of segments) {
41
- currentId = await this.ensureFolder(seg, currentId);
42
- }
43
- this.prefixFolderId = currentId;
44
- log.info(`google-drive backend ready: folder="${this.opts.folderName}" prefix="${this.opts.prefix}" id=${this.prefixFolderId}`);
45
- }
46
- async list() {
47
- return this.listFolder(this.requirePrefixFolderId(), "");
48
- }
49
- async get(key) {
50
- const drive = this.requireDrive();
51
- const { parentId, name } = await this.resolveKey(key);
52
- const fileId = await this.findFileId(name, parentId);
53
- if (!fileId) {
54
- throw new Error(`google-drive: no file named ${key}`);
55
- }
56
- const res = await drive.files.get({ fileId, alt: "media" }, { responseType: "arraybuffer" });
57
- return Buffer.from(res.data);
58
- }
59
- async put(key, data) {
60
- const drive = this.requireDrive();
61
- const { parentId, name } = await this.resolveKey(key);
62
- const existing = await this.findFileId(name, parentId);
63
- const media = { mimeType: FILE_MIME, body: Readable.from(data) };
64
- if (existing) {
65
- await drive.files.update({ fileId: existing, media });
66
- return;
67
- }
68
- await drive.files.create({
69
- requestBody: { name, mimeType: FILE_MIME, parents: [parentId] },
70
- media,
71
- fields: "id",
72
- });
73
- }
74
- async delete(key) {
75
- const { parentId, name } = await this.resolveKey(key);
76
- const fileId = await this.findFileId(name, parentId);
77
- if (!fileId)
78
- return;
79
- // Trash rather than hard-delete — recoverable for 30 days.
80
- await this.requireDrive().files.update({
81
- fileId,
82
- requestBody: { trashed: true },
83
- });
84
- }
85
- requireDrive() {
86
- if (!this.drive)
87
- throw new Error("GoogleDriveBackend used before init()");
88
- return this.drive;
89
- }
90
- requirePrefixFolderId() {
91
- if (!this.prefixFolderId)
92
- throw new Error("GoogleDriveBackend used before init()");
93
- return this.prefixFolderId;
94
- }
95
- // Resolve a key that may contain a host subdirectory segment
96
- // (e.g. "alice-macbook/uuid.hydra.archive") into a (parentId, filename)
97
- // pair, creating intermediate folders as needed.
98
- async resolveKey(key) {
99
- const parts = key.split("/");
100
- const name = parts[parts.length - 1];
101
- let parentId = this.requirePrefixFolderId();
102
- for (const seg of parts.slice(0, -1)) {
103
- parentId = await this.ensureFolder(seg, parentId);
104
- }
105
- return { parentId, name };
106
- }
107
- // Recursive list: returns files in folderId and all immediate subfolders,
108
- // with keys relative to folderId (e.g. "alice-macbook/uuid.hydra.archive").
109
- async listFolder(folderId, relPath) {
110
- const drive = this.requireDrive();
111
- const entries = [];
112
- let pageToken;
113
- do {
114
- const res = await drive.files.list({
115
- q: `'${folderId}' in parents and trashed = false`,
116
- fields: "nextPageToken, files(id, name, size, modifiedTime, mimeType)",
117
- spaces: "drive",
118
- pageSize: 200,
119
- ...(pageToken !== undefined ? { pageToken } : {}),
120
- });
121
- for (const f of res.data.files ?? []) {
122
- if (!f.name)
123
- continue;
124
- const entryRel = relPath !== "" ? `${relPath}/${f.name}` : f.name;
125
- if (f.mimeType === FOLDER_MIME) {
126
- entries.push(...await this.listFolder(f.id, entryRel));
127
- }
128
- else {
129
- entries.push({
130
- key: entryRel,
131
- size: typeof f.size === "string" ? Number.parseInt(f.size, 10) : 0,
132
- modifiedAt: f.modifiedTime ?? new Date(0).toISOString(),
133
- });
134
- }
135
- }
136
- pageToken = res.data.nextPageToken ?? undefined;
137
- } while (pageToken);
138
- return entries;
139
- }
140
- // Ensure a folder with the given name exists under parentId (or at root if
141
- // parentId is undefined). Returns the folder's Drive ID.
142
- async ensureFolder(name, parentId) {
143
- const drive = this.requireDrive();
144
- const escaped = name.replace(/'/g, "\\'");
145
- const parentClause = parentId !== undefined
146
- ? `'${parentId}' in parents and `
147
- : "";
148
- const res = await drive.files.list({
149
- q: `${parentClause}name = '${escaped}' and mimeType = '${FOLDER_MIME}' and trashed = false`,
150
- fields: "files(id, name)",
151
- spaces: "drive",
152
- pageSize: 10,
153
- });
154
- const existing = res.data.files?.[0];
155
- if (existing?.id)
156
- return existing.id;
157
- const created = await drive.files.create({
158
- requestBody: {
159
- name,
160
- mimeType: FOLDER_MIME,
161
- ...(parentId !== undefined ? { parents: [parentId] } : {}),
162
- },
163
- fields: "id",
164
- });
165
- if (!created.data.id)
166
- throw new Error("google-drive: folder create returned no id");
167
- log.info(`created Drive folder "${name}" id=${created.data.id}`);
168
- return created.data.id;
169
- }
170
- async findFileId(name, parentId) {
171
- const drive = this.requireDrive();
172
- const escaped = name.replace(/'/g, "\\'");
173
- const res = await drive.files.list({
174
- q: `'${parentId}' in parents and name = '${escaped}' and trashed = false`,
175
- fields: "files(id, name)",
176
- spaces: "drive",
177
- pageSize: 2,
178
- });
179
- return res.data.files?.[0]?.id ?? undefined;
180
- }
181
- }
182
- //# sourceMappingURL=google-drive.js.map
1
+ import{Readable as c}from"node:stream";import{google as p}from"googleapis";import{loadGoogleAuth as g}from"../oauth/google.js";import{logger as u}from"../util/log.js";const f=u("backend.google-drive"),o="application/vnd.google-apps.folder",l="application/json";class I{constructor(r){this.opts=r}opts;drive;prefixFolderId;async init(){const r=await g({credentialsPath:this.opts.credentialsPath,tokenPath:this.opts.tokenPath});this.drive=p.drive({version:"v3",auth:r});let e=await this.ensureFolder(this.opts.folderName,void 0);const t=this.opts.prefix.split("/").filter(i=>i!=="");for(const i of t)e=await this.ensureFolder(i,e);this.prefixFolderId=e,f.info(`google-drive backend ready: folder="${this.opts.folderName}" prefix="${this.opts.prefix}" id=${this.prefixFolderId}`)}async list(){return this.listFolder(this.requirePrefixFolderId(),"")}async get(r){const e=this.requireDrive(),{parentId:t,name:i}=await this.resolveKey(r),s=await this.findFileId(i,t);if(!s)throw new Error(`google-drive: no file named ${r}`);const a=await e.files.get({fileId:s,alt:"media"},{responseType:"arraybuffer"});return Buffer.from(a.data)}async put(r,e){const t=this.requireDrive(),{parentId:i,name:s}=await this.resolveKey(r),a=await this.findFileId(s,i),n={mimeType:l,body:c.from(e)};if(a){await t.files.update({fileId:a,media:n});return}await t.files.create({requestBody:{name:s,mimeType:l,parents:[i]},media:n,fields:"id"})}async delete(r){const{parentId:e,name:t}=await this.resolveKey(r),i=await this.findFileId(t,e);i&&await this.requireDrive().files.update({fileId:i,requestBody:{trashed:!0}})}requireDrive(){if(!this.drive)throw new Error("GoogleDriveBackend used before init()");return this.drive}requirePrefixFolderId(){if(!this.prefixFolderId)throw new Error("GoogleDriveBackend used before init()");return this.prefixFolderId}async resolveKey(r){const e=r.split("/"),t=e[e.length-1];let i=this.requirePrefixFolderId();for(const s of e.slice(0,-1))i=await this.ensureFolder(s,i);return{parentId:i,name:t}}async listFolder(r,e){const t=this.requireDrive(),i=[];let s;do{const a=await t.files.list({q:`'${r}' in parents and trashed = false`,fields:"nextPageToken, files(id, name, size, modifiedTime, mimeType)",spaces:"drive",pageSize:200,...s!==void 0?{pageToken:s}:{}});for(const n of a.data.files??[]){if(!n.name)continue;const d=e!==""?`${e}/${n.name}`:n.name;n.mimeType===o?i.push(...await this.listFolder(n.id,d)):i.push({key:d,size:typeof n.size=="string"?Number.parseInt(n.size,10):0,modifiedAt:n.modifiedTime??new Date(0).toISOString()})}s=a.data.nextPageToken??void 0}while(s);return i}async ensureFolder(r,e){const t=this.requireDrive(),i=r.replace(/'/g,"\\'"),s=e!==void 0?`'${e}' in parents and `:"",n=(await t.files.list({q:`${s}name = '${i}' and mimeType = '${o}' and trashed = false`,fields:"files(id, name)",spaces:"drive",pageSize:10})).data.files?.[0];if(n?.id)return n.id;const d=await t.files.create({requestBody:{name:r,mimeType:o,...e!==void 0?{parents:[e]}:{}},fields:"id"});if(!d.data.id)throw new Error("google-drive: folder create returned no id");return f.info(`created Drive folder "${r}" id=${d.data.id}`),d.data.id}async findFileId(r,e){const t=this.requireDrive(),i=r.replace(/'/g,"\\'");return(await t.files.list({q:`'${e}' in parents and name = '${i}' and trashed = false`,fields:"files(id, name)",spaces:"drive",pageSize:2})).data.files?.[0]?.id??void 0}}export{I as GoogleDriveBackend};
@@ -1,189 +1,5 @@
1
- import { createHash, createHmac } from "node:crypto";
2
- import { loadAwsCredentials, resolveRegion } from "../util/aws-credentials.js";
3
- import { logger } from "../util/log.js";
4
- const log = logger("backend.s3");
5
- // ── Signature V4 ─────────────────────────────────────────────────────────────
6
- function sha256hex(data) {
7
- return createHash("sha256").update(data).digest("hex");
8
- }
9
- function hmac(key, data) {
10
- return createHmac("sha256", key).update(data).digest();
11
- }
12
- function signingKey(secret, date, region) {
13
- return hmac(hmac(hmac(hmac(`AWS4${secret}`, date), region), "s3"), "aws4_request");
14
- }
15
- function isoDateTime() {
16
- const now = new Date();
17
- const datetime = now.toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z";
18
- return { datetime, date: datetime.slice(0, 8) };
19
- }
20
- function buildAuthHeaders(method, url, body, creds, region) {
21
- const { datetime, date } = isoDateTime();
22
- const bodyHash = sha256hex(body ?? Buffer.alloc(0));
23
- const toSign = [
24
- ["host", url.host],
25
- ["x-amz-content-sha256", bodyHash],
26
- ["x-amz-date", datetime],
27
- ];
28
- if (creds.sessionToken)
29
- toSign.push(["x-amz-security-token", creds.sessionToken]);
30
- toSign.sort((a, b) => a[0].localeCompare(b[0]));
31
- const canonicalHeaders = toSign.map(([k, v]) => `${k}:${v}`).join("\n") + "\n";
32
- const signedHeaderNames = toSign.map(([k]) => k).join(";");
33
- const canonicalQuery = [...url.searchParams.entries()]
34
- .sort((a, b) => a[0].localeCompare(b[0]))
35
- .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
36
- .join("&");
37
- const canonicalRequest = [
38
- method,
39
- url.pathname,
40
- canonicalQuery,
41
- canonicalHeaders,
42
- signedHeaderNames,
43
- bodyHash,
44
- ].join("\n");
45
- const credentialScope = `${date}/${region}/s3/aws4_request`;
46
- const stringToSign = [
47
- "AWS4-HMAC-SHA256",
48
- datetime,
49
- credentialScope,
50
- sha256hex(canonicalRequest),
51
- ].join("\n");
52
- const signature = createHmac("sha256", signingKey(creds.secretAccessKey, date, region))
53
- .update(stringToSign)
54
- .digest("hex");
55
- const result = {
56
- authorization: `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaderNames}, Signature=${signature}`,
57
- "x-amz-content-sha256": bodyHash,
58
- "x-amz-date": datetime,
59
- };
60
- if (creds.sessionToken)
61
- result["x-amz-security-token"] = creds.sessionToken;
62
- return result;
63
- }
64
- // ── URL helpers ───────────────────────────────────────────────────────────────
65
- // Encode each path segment individually, preserving slashes between them.
66
- function encodePath(path) {
67
- return path.split("/").map(encodeURIComponent).join("/");
68
- }
69
- function buildUrl(bucket, keyPath, // leading slash, empty for bucket root ("/" or "/key")
70
- query, region, endpoint) {
71
- const base = endpoint !== undefined
72
- ? `${endpoint.replace(/\/$/, "")}/${bucket}` // path-style for custom endpoints
73
- : `https://${bucket}.s3.${region}.amazonaws.com`; // virtual-hosted for AWS
74
- const url = new URL(`${base}${keyPath}`);
75
- for (const [k, v] of Object.entries(query))
76
- url.searchParams.set(k, v);
77
- return url;
78
- }
79
- // ── XML helpers ───────────────────────────────────────────────────────────────
80
- function xmlFirst(xml, tag) {
81
- const m = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`).exec(xml);
82
- if (!m || m[1] === undefined)
83
- return undefined;
84
- return m[1]
85
- .replace(/&amp;/g, "&")
86
- .replace(/&lt;/g, "<")
87
- .replace(/&gt;/g, ">")
88
- .replace(/&quot;/g, '"')
89
- .replace(/&apos;/g, "'");
90
- }
91
- function parseContents(xml) {
92
- const out = [];
93
- const re = /<Contents>([\s\S]*?)<\/Contents>/g;
94
- let m;
95
- while ((m = re.exec(xml)) !== null) {
96
- const block = m[1];
97
- if (block === undefined)
98
- continue;
99
- const key = xmlFirst(block, "Key");
100
- if (!key)
101
- continue;
102
- out.push({
103
- key,
104
- size: Number.parseInt(xmlFirst(block, "Size") ?? "0", 10),
105
- modifiedAt: xmlFirst(block, "LastModified") ?? new Date(0).toISOString(),
106
- });
107
- }
108
- return out;
109
- }
110
- // ── HTTP request ─────────────────────────────────────────────────────────────
111
- async function s3Fetch(method, url, body, creds, region) {
112
- const headers = buildAuthHeaders(method, url, body, creds, region);
113
- if (body !== undefined)
114
- headers["content-length"] = String(body.length);
115
- const res = await fetch(url, {
116
- method,
117
- headers,
118
- ...(body !== undefined ? { body } : {}),
119
- });
120
- if (!res.ok) {
121
- const text = await res.text().catch(() => "");
122
- const code = xmlFirst(text, "Code") ?? String(res.status);
123
- const msg = xmlFirst(text, "Message") ?? res.statusText;
124
- throw new Error(`s3: ${method} ${url.pathname} — ${code}: ${msg}`);
125
- }
126
- return res;
127
- }
128
- // ── Backend ───────────────────────────────────────────────────────────────────
129
- export class S3Backend {
130
- opts;
131
- credentials;
132
- region;
133
- prefix;
134
- constructor(opts) {
135
- this.opts = opts;
136
- this.region = resolveRegion(opts.region);
137
- this.prefix = opts.prefix;
138
- }
139
- async init() {
140
- this.credentials = loadAwsCredentials();
141
- await s3Fetch("HEAD", buildUrl(this.opts.bucket, "/", {}, this.region, this.opts.endpoint), undefined, this.credentials, this.region);
142
- log.info(`s3 backend ready: bucket=${this.opts.bucket}${this.prefix !== "" ? ` prefix="${this.prefix}"` : ""}`);
143
- }
144
- async list() {
145
- const creds = this.requireCredentials();
146
- const entries = [];
147
- let continuationToken;
148
- do {
149
- const query = { "list-type": "2" };
150
- if (this.prefix !== "")
151
- query["prefix"] = this.prefix;
152
- if (continuationToken !== undefined)
153
- query["continuation-token"] = continuationToken;
154
- const url = buildUrl(this.opts.bucket, "/", query, this.region, this.opts.endpoint);
155
- const xml = await (await s3Fetch("GET", url, undefined, creds, this.region)).text();
156
- for (const obj of parseContents(xml)) {
157
- entries.push({
158
- key: this.prefix !== "" ? obj.key.slice(this.prefix.length) : obj.key,
159
- size: obj.size,
160
- modifiedAt: obj.modifiedAt,
161
- });
162
- }
163
- continuationToken =
164
- xmlFirst(xml, "IsTruncated") === "true"
165
- ? xmlFirst(xml, "NextContinuationToken")
166
- : undefined;
167
- } while (continuationToken !== undefined);
168
- return entries;
169
- }
170
- async get(key) {
171
- const creds = this.requireCredentials();
172
- const url = buildUrl(this.opts.bucket, `/${encodePath(this.prefix + key)}`, {}, this.region, this.opts.endpoint);
173
- return Buffer.from(await (await s3Fetch("GET", url, undefined, creds, this.region)).arrayBuffer());
174
- }
175
- async put(key, data) {
176
- const creds = this.requireCredentials();
177
- await s3Fetch("PUT", buildUrl(this.opts.bucket, `/${encodePath(this.prefix + key)}`, {}, this.region, this.opts.endpoint), data, creds, this.region);
178
- }
179
- async delete(key) {
180
- const creds = this.requireCredentials();
181
- await s3Fetch("DELETE", buildUrl(this.opts.bucket, `/${encodePath(this.prefix + key)}`, {}, this.region, this.opts.endpoint), undefined, creds, this.region);
182
- }
183
- requireCredentials() {
184
- if (!this.credentials)
185
- throw new Error("S3Backend used before init()");
186
- return this.credentials;
187
- }
188
- }
189
- //# sourceMappingURL=s3.js.map
1
+ import{createHash as b,createHmac as S}from"node:crypto";import{loadAwsCredentials as R,resolveRegion as E}from"../util/aws-credentials.js";import{logger as T}from"../util/log.js";const z=T("backend.s3");function w(n){return b("sha256").update(n).digest("hex")}function h(n,e){return S("sha256",n).update(e).digest()}function j(n,e,t){return h(h(h(h(`AWS4${n}`,e),t),"s3"),"aws4_request")}function H(){const e=new Date().toISOString().replace(/[-:]/g,"").slice(0,15)+"Z";return{datetime:e,date:e.slice(0,8)}}function q(n,e,t,i,s){const{datetime:o,date:r}=H(),a=w(t??Buffer.alloc(0)),d=[["host",e.host],["x-amz-content-sha256",a],["x-amz-date",o]];i.sessionToken&&d.push(["x-amz-security-token",i.sessionToken]),d.sort((c,u)=>c[0].localeCompare(u[0]));const l=d.map(([c,u])=>`${c}:${u}`).join(`
2
+ `)+`
3
+ `,x=d.map(([c])=>c).join(";"),$=[...e.searchParams.entries()].sort((c,u)=>c[0].localeCompare(u[0])).map(([c,u])=>`${encodeURIComponent(c)}=${encodeURIComponent(u)}`).join("&"),C=[n,e.pathname,$,l,x,a].join(`
4
+ `),k=`${r}/${s}/s3/aws4_request`,A=["AWS4-HMAC-SHA256",o,k,w(C)].join(`
5
+ `),B=S("sha256",j(i.secretAccessKey,r,s)).update(A).digest("hex"),y={authorization:`AWS4-HMAC-SHA256 Credential=${i.accessKeyId}/${k}, SignedHeaders=${x}, Signature=${B}`,"x-amz-content-sha256":a,"x-amz-date":o};return i.sessionToken&&(y["x-amz-security-token"]=i.sessionToken),y}function m(n){return n.split("/").map(encodeURIComponent).join("/")}function g(n,e,t,i,s){const o=s!==void 0?`${s.replace(/\/$/,"")}/${n}`:`https://${n}.s3.${i}.amazonaws.com`,r=new URL(`${o}${e}`);for(const[a,d]of Object.entries(t))r.searchParams.set(a,d);return r}function f(n,e){const t=new RegExp(`<${e}(?:\\s[^>]*)?>([\\s\\S]*?)</${e}>`).exec(n);if(!(!t||t[1]===void 0))return t[1].replace(/&amp;/g,"&").replace(/&lt;/g,"<").replace(/&gt;/g,">").replace(/&quot;/g,'"').replace(/&apos;/g,"'")}function v(n){const e=[],t=/<Contents>([\s\S]*?)<\/Contents>/g;let i;for(;(i=t.exec(n))!==null;){const s=i[1];if(s===void 0)continue;const o=f(s,"Key");o&&e.push({key:o,size:Number.parseInt(f(s,"Size")??"0",10),modifiedAt:f(s,"LastModified")??new Date(0).toISOString()})}return e}async function p(n,e,t,i,s){const o=q(n,e,t,i,s);t!==void 0&&(o["content-length"]=String(t.length));const r=await fetch(e,{method:n,headers:o,...t!==void 0?{body:t}:{}});if(!r.ok){const a=await r.text().catch(()=>""),d=f(a,"Code")??String(r.status),l=f(a,"Message")??r.statusText;throw new Error(`s3: ${n} ${e.pathname} \u2014 ${d}: ${l}`)}return r}class O{constructor(e){this.opts=e;this.region=E(e.region),this.prefix=e.prefix}opts;credentials;region;prefix;async init(){this.credentials=R(),await p("HEAD",g(this.opts.bucket,"/",{},this.region,this.opts.endpoint),void 0,this.credentials,this.region),z.info(`s3 backend ready: bucket=${this.opts.bucket}${this.prefix!==""?` prefix="${this.prefix}"`:""}`)}async list(){const e=this.requireCredentials(),t=[];let i;do{const s={"list-type":"2"};this.prefix!==""&&(s.prefix=this.prefix),i!==void 0&&(s["continuation-token"]=i);const o=g(this.opts.bucket,"/",s,this.region,this.opts.endpoint),r=await(await p("GET",o,void 0,e,this.region)).text();for(const a of v(r))t.push({key:this.prefix!==""?a.key.slice(this.prefix.length):a.key,size:a.size,modifiedAt:a.modifiedAt});i=f(r,"IsTruncated")==="true"?f(r,"NextContinuationToken"):void 0}while(i!==void 0);return t}async get(e){const t=this.requireCredentials(),i=g(this.opts.bucket,`/${m(this.prefix+e)}`,{},this.region,this.opts.endpoint);return Buffer.from(await(await p("GET",i,void 0,t,this.region)).arrayBuffer())}async put(e,t){const i=this.requireCredentials();await p("PUT",g(this.opts.bucket,`/${m(this.prefix+e)}`,{},this.region,this.opts.endpoint),t,i,this.region)}async delete(e){const t=this.requireCredentials();await p("DELETE",g(this.opts.bucket,`/${m(this.prefix+e)}`,{},this.region,this.opts.endpoint),void 0,t,this.region)}requireCredentials(){if(!this.credentials)throw new Error("S3Backend used before init()");return this.credentials}}export{O as S3Backend};
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=types.js.map
package/dist/bridge.js CHANGED
@@ -1,100 +1 @@
1
- import { AcpAttach } from "./acp/attach.js";
2
- import { logger } from "./util/log.js";
3
- const log = logger("bridge");
4
- // One bridge per discovered session. Listens to session/update for
5
- // turn_complete and tells the archive loop to schedule an upload.
6
- // session_info_update keeps the cached meta fresh so any rule logic
7
- // that reads title/agentId sees the latest values.
8
- export class ArchiverBridge {
9
- opts;
10
- attach;
11
- meta;
12
- stopped = false;
13
- constructor(opts) {
14
- this.opts = opts;
15
- this.meta = opts.meta;
16
- this.attach = new AcpAttach({
17
- sessionId: opts.sessionId,
18
- daemonWsUrl: opts.daemonWsUrl,
19
- token: opts.token,
20
- });
21
- this.opts.archive.setMeta(opts.sessionId, this.meta);
22
- }
23
- start() {
24
- this.attach.on("notification", (n) => this.onNotification(n));
25
- this.attach.on("request", (r) => this.onRequest(r));
26
- this.attach.on("error", (err) => {
27
- log.warn(`attach error ${this.opts.sessionId}: ${err.message}`);
28
- });
29
- this.attach.start();
30
- }
31
- stop() {
32
- if (this.stopped) {
33
- return;
34
- }
35
- this.stopped = true;
36
- // Fire-and-forget a final flush so any pending debounced upload
37
- // lands before we drop the session. The finalFlush method swallows
38
- // errors and clears the timer + meta itself.
39
- void this.opts.archive.finalFlush(this.opts.sessionId);
40
- this.attach.stop();
41
- }
42
- updateMeta(meta) {
43
- this.meta = meta;
44
- this.opts.archive.setMeta(this.opts.sessionId, meta);
45
- }
46
- onNotification(n) {
47
- if (n.method !== "session/update") {
48
- return;
49
- }
50
- const params = (n.params ?? {});
51
- const update = (params.update ?? {});
52
- const kind = typeof update.sessionUpdate === "string" ? update.sessionUpdate : "";
53
- if (kind === "session_info_update") {
54
- this.applySessionInfoUpdate(update);
55
- }
56
- // turn_complete is the primary upload trigger. Any other kind of
57
- // session/update is ignored — we don't want to upload after every
58
- // tool call notification, just after a turn finishes.
59
- if (kind === "turn_complete") {
60
- this.opts.archive.markDirty(this.opts.sessionId);
61
- }
62
- }
63
- applySessionInfoUpdate(update) {
64
- const next = { ...this.meta };
65
- let changed = false;
66
- if (typeof update.title === "string" && next.title !== update.title) {
67
- next.title = update.title;
68
- changed = true;
69
- }
70
- const agentId = readHydraAgentId(update._meta);
71
- if (agentId !== undefined && next.agentId !== agentId) {
72
- next.agentId = agentId;
73
- changed = true;
74
- }
75
- if (changed) {
76
- this.updateMeta(next);
77
- }
78
- }
79
- onRequest(r) {
80
- // The hydra-acp daemon broadcasts agent→client requests to every
81
- // attached client and resolves the original on the first response
82
- // (first-responder-wins). A passive observer like the archiver
83
- // MUST stay silent on methods it doesn't intend to answer —
84
- // replying -32601 would race the real client and make permission
85
- // prompts / fs reads etc. resolve to an error. Just log and drop.
86
- log.debug(`ignoring inbound request ${r.method} id=${String(r.id)}`);
87
- }
88
- }
89
- function readHydraAgentId(meta) {
90
- if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
91
- return undefined;
92
- }
93
- const ns = meta["hydra-acp"];
94
- if (!ns || typeof ns !== "object" || Array.isArray(ns)) {
95
- return undefined;
96
- }
97
- const v = ns.agentId;
98
- return typeof v === "string" ? v : undefined;
99
- }
100
- //# sourceMappingURL=bridge.js.map
1
+ import{AcpAttach as r}from"./acp/attach.js";import{logger as a}from"./util/log.js";const n=a("bridge");class h{constructor(t){this.opts=t;this.meta=t.meta,this.attach=new r({sessionId:t.sessionId,daemonWsUrl:t.daemonWsUrl,token:t.token}),this.opts.archive.setMeta(t.sessionId,this.meta)}opts;attach;meta;stopped=!1;start(){this.attach.on("notification",t=>this.onNotification(t)),this.attach.on("request",t=>this.onRequest(t)),this.attach.on("error",t=>{n.warn(`attach error ${this.opts.sessionId}: ${t.message}`)}),this.attach.start()}stop(){this.stopped||(this.stopped=!0,this.opts.archive.finalFlush(this.opts.sessionId),this.attach.stop())}updateMeta(t){this.meta=t,this.opts.archive.setMeta(this.opts.sessionId,t)}onNotification(t){if(t.method!=="session/update")return;const s=(t.params??{}).update??{},i=typeof s.sessionUpdate=="string"?s.sessionUpdate:"";i==="session_info_update"&&this.applySessionInfoUpdate(s),i==="turn_complete"&&this.opts.archive.markDirty(this.opts.sessionId)}applySessionInfoUpdate(t){const e={...this.meta};let s=!1;typeof t.title=="string"&&e.title!==t.title&&(e.title=t.title,s=!0);const i=d(t._meta);i!==void 0&&e.agentId!==i&&(e.agentId=i,s=!0),s&&this.updateMeta(e)}onRequest(t){n.debug(`ignoring inbound request ${t.method} id=${String(t.id)}`)}}function d(o){if(!o||typeof o!="object"||Array.isArray(o))return;const t=o["hydra-acp"];if(!t||typeof t!="object"||Array.isArray(t))return;const e=t.agentId;return typeof e=="string"?e:void 0}export{h as ArchiverBridge};
@@ -1,54 +1 @@
1
- import { logger } from "./util/log.js";
2
- const log = logger("cold-sweep");
3
- // One-shot scan of every session the daemon knows about, exporting any
4
- // cold ones. Live sessions are skipped here — they're handled by the
5
- // per-session bridge once discovery sees them. The archive loop's
6
- // hash-dedup ensures cold sessions that haven't changed since their
7
- // last upload are no-ops on the backend.
8
- //
9
- // Runs sequentially to avoid hammering the daemon and the backend
10
- // (especially Drive, which rate-limits). Errors on individual sessions
11
- // are logged and skipped — partial progress is preferable to bailing.
12
- export async function runColdSweep(opts) {
13
- const sessions = await listSessions(opts.daemonUrl, opts.token);
14
- let cold = 0;
15
- let skippedMirrors = 0;
16
- for (const s of sessions) {
17
- if (s.status === "live") {
18
- continue;
19
- }
20
- // Passive mirror: imported from a peer, never opened locally. No
21
- // upstreamSessionId means no local agent has bound it, so this
22
- // machine has nothing to contribute. Re-exporting would just
23
- // ping-pong the bundle back to the peer.
24
- if (s.importedFromMachine && !s.upstreamSessionId) {
25
- skippedMirrors += 1;
26
- continue;
27
- }
28
- cold += 1;
29
- opts.archive.setMeta(s.sessionId, {
30
- ...(s.cwd !== undefined ? { cwd: s.cwd } : {}),
31
- ...(s.agentId !== undefined ? { agentId: s.agentId } : {}),
32
- ...(s.title !== undefined ? { title: s.title } : {}),
33
- });
34
- try {
35
- await opts.archive.flushNow(s.sessionId);
36
- }
37
- catch (err) {
38
- log.warn(`cold sweep flush ${s.sessionId} failed: ${err.message}`);
39
- }
40
- }
41
- log.info(`cold sweep done: scanned=${sessions.length} cold=${cold} skipped-mirrors=${skippedMirrors}`);
42
- return { scanned: sessions.length, cold, skippedMirrors };
43
- }
44
- async function listSessions(daemonUrl, token) {
45
- const r = await fetch(`${daemonUrl}/v1/sessions`, {
46
- headers: { Authorization: `Bearer ${token}` },
47
- });
48
- if (!r.ok) {
49
- throw new Error(`daemon /v1/sessions returned ${r.status}`);
50
- }
51
- const body = (await r.json());
52
- return body.sessions;
53
- }
54
- //# sourceMappingURL=cold-sweep.js.map
1
+ import{logger as d}from"./util/log.js";const i=d("cold-sweep");async function l(o){const n=await a(o.daemonUrl,o.token);let s=0,r=0;for(const e of n)if(e.status!=="live"){if(e.importedFromMachine&&!e.upstreamSessionId){r+=1;continue}s+=1,o.archive.setMeta(e.sessionId,{...e.cwd!==void 0?{cwd:e.cwd}:{},...e.agentId!==void 0?{agentId:e.agentId}:{},...e.title!==void 0?{title:e.title}:{}});try{await o.archive.flushNow(e.sessionId)}catch(t){i.warn(`cold sweep flush ${e.sessionId} failed: ${t.message}`)}}return i.info(`cold sweep done: scanned=${n.length} cold=${s} skipped-mirrors=${r}`),{scanned:n.length,cold:s,skippedMirrors:r}}async function a(o,n){const s=await fetch(`${o}/v1/sessions`,{headers:{Authorization:`Bearer ${n}`}});if(!s.ok)throw new Error(`daemon /v1/sessions returned ${s.status}`);return(await s.json()).sessions}export{l as runColdSweep};