@indigoai-us/hq-cloud 6.5.0 → 6.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner.d.ts +4 -35
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +14 -104
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +19 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +67 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +19 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +62 -19
- package/dist/cli/sync.js.map +1 -1
- package/dist/cognito-auth.d.ts +27 -0
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +97 -0
- package/dist/cognito-auth.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/machine-auth.test.d.ts +14 -0
- package/dist/machine-auth.test.d.ts.map +1 -0
- package/dist/machine-auth.test.js +216 -0
- package/dist/machine-auth.test.js.map +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +15 -5
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +71 -2
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +70 -7
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +102 -23
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +63 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +50 -0
- package/dist/sync/pull-scope.d.ts.map +1 -0
- package/dist/sync/pull-scope.js +129 -0
- package/dist/sync/pull-scope.js.map +1 -0
- package/package.json +2 -2
- package/src/bin/sync-runner.test.ts +23 -0
- package/src/bin/sync-runner.ts +19 -116
- package/src/cli/sync-scope.test.ts +84 -0
- package/src/cli/sync.ts +90 -17
- package/src/cognito-auth.ts +159 -0
- package/src/index.ts +21 -1
- package/src/machine-auth.test.ts +279 -0
- package/src/s3.test.ts +91 -1
- package/src/s3.ts +15 -5
- package/src/scope-shrink.test.ts +71 -0
- package/src/scope-shrink.ts +164 -20
- package/src/sync/pull-scope.ts +161 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the machine-identity auth mode in cognito-auth.ts.
|
|
3
|
+
*
|
|
4
|
+
* Machine identities (company agents) carry long-lived Cognito creds at
|
|
5
|
+
* ~/.hq-agent/machine-creds.json (HQ_MACHINE_CREDS_FILE override) and mint
|
|
6
|
+
* sessions via USER_PASSWORD_AUTH on demand — no browser, no refresh token.
|
|
7
|
+
* The contract under test:
|
|
8
|
+
* - detection: creds file present + well-formed → machine mode
|
|
9
|
+
* - minting: BOTH tokens cached with correct field semantics
|
|
10
|
+
* - re-mint on expiry; cache hit when valid
|
|
11
|
+
* - getValidAccessToken short-circuits into machine mode (never browser)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
import * as os from "os";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
|
|
19
|
+
let originalHome: string | undefined;
|
|
20
|
+
let originalCredsEnv: string | undefined;
|
|
21
|
+
let tmpHome: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
originalHome = process.env.HOME;
|
|
25
|
+
originalCredsEnv = process.env.HQ_MACHINE_CREDS_FILE;
|
|
26
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "hq-machine-auth-test-"));
|
|
27
|
+
process.env.HOME = tmpHome;
|
|
28
|
+
delete process.env.HQ_MACHINE_CREDS_FILE;
|
|
29
|
+
vi.resetModules();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
if (originalHome === undefined) delete process.env.HOME;
|
|
34
|
+
else process.env.HOME = originalHome;
|
|
35
|
+
if (originalCredsEnv === undefined) delete process.env.HQ_MACHINE_CREDS_FILE;
|
|
36
|
+
else process.env.HQ_MACHINE_CREDS_FILE = originalCredsEnv;
|
|
37
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
38
|
+
vi.unstubAllGlobals();
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
async function importModule() {
|
|
43
|
+
return await import("./cognito-auth.js");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const CONFIG = {
|
|
47
|
+
region: "us-east-1",
|
|
48
|
+
userPoolDomain: "vault-indigo-hq-prod",
|
|
49
|
+
clientId: "test-client-id",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function writeCreds(
|
|
53
|
+
creds: unknown = { username: "machine-agt_01TEST", secret: "s3cret" },
|
|
54
|
+
): string {
|
|
55
|
+
const dir = path.join(tmpHome, ".hq-agent");
|
|
56
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
57
|
+
const file = path.join(dir, "machine-creds.json");
|
|
58
|
+
fs.writeFileSync(file, JSON.stringify(creds));
|
|
59
|
+
return file;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Build a fake JWT whose payload decodes to the given claims. */
|
|
63
|
+
function fakeJwt(claims: Record<string, unknown>): string {
|
|
64
|
+
const enc = (o: unknown) =>
|
|
65
|
+
Buffer.from(JSON.stringify(o)).toString("base64url");
|
|
66
|
+
return `${enc({ alg: "RS256", kid: "k" })}.${enc(claims)}.sig`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stubMintFetch(
|
|
70
|
+
overrides: Partial<{
|
|
71
|
+
AccessToken: string;
|
|
72
|
+
IdToken: string;
|
|
73
|
+
RefreshToken: string;
|
|
74
|
+
ExpiresIn: number;
|
|
75
|
+
}> = {},
|
|
76
|
+
) {
|
|
77
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
78
|
+
const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
79
|
+
calls.push({ url: String(url), init: init ?? {} });
|
|
80
|
+
return new Response(
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
AuthenticationResult: {
|
|
83
|
+
AccessToken:
|
|
84
|
+
overrides.AccessToken ??
|
|
85
|
+
fakeJwt({ token_use: "access", client_id: CONFIG.clientId }),
|
|
86
|
+
IdToken: overrides.IdToken ?? fakeJwt({ token_use: "id" }),
|
|
87
|
+
RefreshToken: overrides.RefreshToken,
|
|
88
|
+
ExpiresIn: overrides.ExpiresIn ?? 3600,
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
{ status: 200 },
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
95
|
+
return { fetchMock, calls };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Detection
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe("machine identity detection", () => {
|
|
103
|
+
it("is off when no creds file exists", async () => {
|
|
104
|
+
const { isMachineIdentity, loadMachineCreds } = await importModule();
|
|
105
|
+
expect(loadMachineCreds()).toBeNull();
|
|
106
|
+
expect(isMachineIdentity()).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("detects creds at the default ~/.hq-agent path", async () => {
|
|
110
|
+
writeCreds();
|
|
111
|
+
const { isMachineIdentity, loadMachineCreds } = await importModule();
|
|
112
|
+
expect(loadMachineCreds()).toEqual({
|
|
113
|
+
username: "machine-agt_01TEST",
|
|
114
|
+
secret: "s3cret",
|
|
115
|
+
});
|
|
116
|
+
expect(isMachineIdentity()).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("honors HQ_MACHINE_CREDS_FILE override", async () => {
|
|
120
|
+
const custom = path.join(tmpHome, "elsewhere.json");
|
|
121
|
+
fs.writeFileSync(
|
|
122
|
+
custom,
|
|
123
|
+
JSON.stringify({ username: "machine-agt_X", secret: "y" }),
|
|
124
|
+
);
|
|
125
|
+
process.env.HQ_MACHINE_CREDS_FILE = custom;
|
|
126
|
+
const { loadMachineCreds, machineCredsFilePath } = await importModule();
|
|
127
|
+
expect(machineCredsFilePath()).toBe(custom);
|
|
128
|
+
expect(loadMachineCreds()?.username).toBe("machine-agt_X");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("rejects malformed creds (wrong username prefix, missing secret, bad JSON)", async () => {
|
|
132
|
+
const { loadMachineCreds } = await importModule();
|
|
133
|
+
writeCreds({ username: "stefan@example.com", secret: "x" });
|
|
134
|
+
expect(loadMachineCreds()).toBeNull();
|
|
135
|
+
writeCreds({ username: "machine-agt_01TEST" });
|
|
136
|
+
expect(loadMachineCreds()).toBeNull();
|
|
137
|
+
fs.writeFileSync(path.join(tmpHome, ".hq-agent", "machine-creds.json"), "{nope");
|
|
138
|
+
expect(loadMachineCreds()).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Minting
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
describe("mintMachineTokens", () => {
|
|
147
|
+
it("mints via USER_PASSWORD_AUTH and caches both tokens with correct fields", async () => {
|
|
148
|
+
writeCreds();
|
|
149
|
+
const { calls } = stubMintFetch({ RefreshToken: "rt" });
|
|
150
|
+
const { mintMachineTokens, loadCachedTokens } = await importModule();
|
|
151
|
+
|
|
152
|
+
const tokens = await mintMachineTokens(CONFIG);
|
|
153
|
+
|
|
154
|
+
// Request shape: Cognito IDP InitiateAuth with the machine creds.
|
|
155
|
+
expect(calls).toHaveLength(1);
|
|
156
|
+
expect(calls[0].url).toBe("https://cognito-idp.us-east-1.amazonaws.com/");
|
|
157
|
+
const body = JSON.parse(String(calls[0].init.body));
|
|
158
|
+
expect(body).toMatchObject({
|
|
159
|
+
AuthFlow: "USER_PASSWORD_AUTH",
|
|
160
|
+
ClientId: CONFIG.clientId,
|
|
161
|
+
AuthParameters: { USERNAME: "machine-agt_01TEST", PASSWORD: "s3cret" },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Field semantics: access token in accessToken, id token in idToken.
|
|
165
|
+
const accessClaims = JSON.parse(
|
|
166
|
+
Buffer.from(tokens.accessToken.split(".")[1], "base64url").toString(),
|
|
167
|
+
);
|
|
168
|
+
const idClaims = JSON.parse(
|
|
169
|
+
Buffer.from(tokens.idToken.split(".")[1], "base64url").toString(),
|
|
170
|
+
);
|
|
171
|
+
expect(accessClaims.token_use).toBe("access");
|
|
172
|
+
expect(idClaims.token_use).toBe("id");
|
|
173
|
+
expect(tokens.refreshToken).toBe("rt");
|
|
174
|
+
expect(typeof tokens.expiresAt).toBe("number");
|
|
175
|
+
|
|
176
|
+
// Persisted to the shared cache file.
|
|
177
|
+
expect(loadCachedTokens()).toEqual(tokens);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("throws CognitoAuthError on auth failure", async () => {
|
|
181
|
+
writeCreds();
|
|
182
|
+
vi.stubGlobal(
|
|
183
|
+
"fetch",
|
|
184
|
+
vi.fn(async () =>
|
|
185
|
+
new Response(
|
|
186
|
+
JSON.stringify({ __type: "NotAuthorizedException", message: "nope" }),
|
|
187
|
+
{ status: 400 },
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
const { mintMachineTokens, CognitoAuthError } = await importModule();
|
|
192
|
+
await expect(mintMachineTokens(CONFIG)).rejects.toBeInstanceOf(
|
|
193
|
+
CognitoAuthError,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("throws when no creds are present", async () => {
|
|
198
|
+
const { mintMachineTokens, CognitoAuthError } = await importModule();
|
|
199
|
+
await expect(mintMachineTokens(CONFIG)).rejects.toBeInstanceOf(
|
|
200
|
+
CognitoAuthError,
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// getValidMachineTokens — cache vs re-mint
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
describe("getValidMachineTokens", () => {
|
|
210
|
+
it("returns the cache when valid without touching the network", async () => {
|
|
211
|
+
writeCreds();
|
|
212
|
+
const { fetchMock } = stubMintFetch();
|
|
213
|
+
const { saveCachedTokens, getValidMachineTokens } = await importModule();
|
|
214
|
+
const cached = {
|
|
215
|
+
accessToken: fakeJwt({ token_use: "access", client_id: CONFIG.clientId }),
|
|
216
|
+
idToken: fakeJwt({ token_use: "id" }),
|
|
217
|
+
refreshToken: "",
|
|
218
|
+
expiresAt: Date.now() + 30 * 60 * 1000,
|
|
219
|
+
tokenType: "Bearer" as const,
|
|
220
|
+
};
|
|
221
|
+
saveCachedTokens(cached);
|
|
222
|
+
|
|
223
|
+
const tokens = await getValidMachineTokens(CONFIG);
|
|
224
|
+
expect(tokens).toEqual(cached);
|
|
225
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("re-mints when the cache is expiring", async () => {
|
|
229
|
+
writeCreds();
|
|
230
|
+
const { fetchMock } = stubMintFetch();
|
|
231
|
+
const { saveCachedTokens, getValidMachineTokens } = await importModule();
|
|
232
|
+
saveCachedTokens({
|
|
233
|
+
accessToken: fakeJwt({ token_use: "access", client_id: CONFIG.clientId }),
|
|
234
|
+
idToken: fakeJwt({ token_use: "id" }),
|
|
235
|
+
refreshToken: "",
|
|
236
|
+
expiresAt: Date.now() + 10 * 1000,
|
|
237
|
+
tokenType: "Bearer",
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await getValidMachineTokens(CONFIG);
|
|
241
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("re-mints when the cached token targets a different app client", async () => {
|
|
245
|
+
writeCreds();
|
|
246
|
+
const { fetchMock } = stubMintFetch();
|
|
247
|
+
const { saveCachedTokens, getValidMachineTokens } = await importModule();
|
|
248
|
+
saveCachedTokens({
|
|
249
|
+
accessToken: fakeJwt({ token_use: "access", client_id: "other-client" }),
|
|
250
|
+
idToken: fakeJwt({ token_use: "id" }),
|
|
251
|
+
refreshToken: "",
|
|
252
|
+
expiresAt: Date.now() + 30 * 60 * 1000,
|
|
253
|
+
tokenType: "Bearer",
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await getValidMachineTokens(CONFIG);
|
|
257
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// getValidAccessToken — machine-mode short circuit
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
describe("getValidAccessToken in machine mode", () => {
|
|
266
|
+
it("mints via machine creds instead of refreshing or opening a browser", async () => {
|
|
267
|
+
writeCreds();
|
|
268
|
+
const { calls } = stubMintFetch();
|
|
269
|
+
const { getValidAccessToken } = await importModule();
|
|
270
|
+
|
|
271
|
+
const token = await getValidAccessToken(CONFIG, { interactive: false });
|
|
272
|
+
|
|
273
|
+
expect(calls).toHaveLength(1);
|
|
274
|
+
const claims = JSON.parse(
|
|
275
|
+
Buffer.from(token.split(".")[1], "base64url").toString(),
|
|
276
|
+
);
|
|
277
|
+
expect(claims.token_use).toBe("access");
|
|
278
|
+
});
|
|
279
|
+
});
|
package/src/s3.test.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `Metadata`, so the listing's HEAD fan-out had nothing to attribute.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
10
10
|
import * as fs from "fs";
|
|
11
11
|
import * as os from "os";
|
|
12
12
|
import * as path from "path";
|
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
uploadFile,
|
|
84
84
|
uploadSymlink,
|
|
85
85
|
toPosixKey,
|
|
86
|
+
primeUploads,
|
|
86
87
|
downloadFile,
|
|
87
88
|
listRemoteFiles,
|
|
88
89
|
SYMLINK_BODY_PREFIX,
|
|
@@ -92,6 +93,12 @@ import {
|
|
|
92
93
|
FILE_MTIME_META_KEY,
|
|
93
94
|
FILE_BTIME_META_KEY,
|
|
94
95
|
} from "./s3.js";
|
|
96
|
+
import {
|
|
97
|
+
setObjectIOFactory,
|
|
98
|
+
presignObjectIOFactory,
|
|
99
|
+
type PresignTransportClient,
|
|
100
|
+
} from "./object-io.js";
|
|
101
|
+
import type { PresignResultRow } from "./vault-client.js";
|
|
95
102
|
import type { EntityContext } from "./types.js";
|
|
96
103
|
|
|
97
104
|
function makeCtx(): EntityContext {
|
|
@@ -158,6 +165,89 @@ describe("backslash key normalization (Windows client → POSIX S3 key)", () =>
|
|
|
158
165
|
});
|
|
159
166
|
});
|
|
160
167
|
|
|
168
|
+
describe("backslash key normalization on the PRESIGNED (GA) transport", () => {
|
|
169
|
+
// The tests above assert normalization on the S3-SDK transport (sentCommands
|
|
170
|
+
// capture SDK PutObjectCommands). As of 6.5.0 the presigned-URL transport is
|
|
171
|
+
// GA for every company vault (cmp_*) — that's the path a real Windows client
|
|
172
|
+
// like Ridge's now takes. These lock the SAME write-side invariant on that
|
|
173
|
+
// transport: a backslash key is canonicalized to POSIX before the server is
|
|
174
|
+
// ever asked to presign it, so a non-POSIX vault key can never be minted.
|
|
175
|
+
let tmpFile: string;
|
|
176
|
+
let presignCalls: Array<{ op?: string; keys: Array<{ key?: string }> }>;
|
|
177
|
+
|
|
178
|
+
function presignKeysFor(op: string): string[] {
|
|
179
|
+
return presignCalls
|
|
180
|
+
.filter((c) => c.op === op)
|
|
181
|
+
.flatMap((c) => c.keys.map((k) => k.key ?? ""));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
beforeEach(() => {
|
|
185
|
+
tmpFile = path.join(
|
|
186
|
+
os.tmpdir(),
|
|
187
|
+
`s3-presign-backslash-${Date.now()}-${Math.random()}.md`,
|
|
188
|
+
);
|
|
189
|
+
fs.writeFileSync(tmpFile, "hello");
|
|
190
|
+
|
|
191
|
+
presignCalls = [];
|
|
192
|
+
const vault: PresignTransportClient = {
|
|
193
|
+
// Echo each requested key back as a usable URL so putObject's PUT
|
|
194
|
+
// resolves; record the op + keys so we can assert what was signed.
|
|
195
|
+
presign: async (input) => {
|
|
196
|
+
presignCalls.push({ op: input.op, keys: input.keys as Array<{ key?: string }> });
|
|
197
|
+
const results: PresignResultRow[] = input.keys.map((k) => ({
|
|
198
|
+
key: (k as { key: string }).key,
|
|
199
|
+
op: input.op ?? "put",
|
|
200
|
+
url: `https://s3.test/${(k as { key: string }).key}`,
|
|
201
|
+
}));
|
|
202
|
+
return { results, expiresAt: "2099-01-01T00:00:00.000Z" };
|
|
203
|
+
},
|
|
204
|
+
listFiles: async () => ({ objects: [], cursor: null, truncated: false }),
|
|
205
|
+
};
|
|
206
|
+
setObjectIOFactory(presignObjectIOFactory(vault));
|
|
207
|
+
// putObject moves bytes over the presigned URL via global fetch.
|
|
208
|
+
vi.stubGlobal(
|
|
209
|
+
"fetch",
|
|
210
|
+
vi.fn(async () => new Response(null, { status: 200, headers: { etag: '"e"' } })),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
afterEach(() => {
|
|
215
|
+
setObjectIOFactory(null);
|
|
216
|
+
vi.unstubAllGlobals();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("uploadFile presigns + PUTs a POSIX key for a Windows-style backslash path", async () => {
|
|
220
|
+
await uploadFile(makeCtx(), tmpFile, "knowledge\\books-eoi.md");
|
|
221
|
+
expect(presignKeysFor("put")).toContain("knowledge/books-eoi.md");
|
|
222
|
+
// The malformed backslash key is never presigned (so never PUT).
|
|
223
|
+
expect(presignKeysFor("put")).not.toContain("knowledge\\books-eoi.md");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("uploadSymlink presigns a POSIX key for a Windows-style backslash path", async () => {
|
|
227
|
+
await uploadSymlink(makeCtx(), "../target.md", "data\\boots-accounts.json");
|
|
228
|
+
expect(presignKeysFor("put")).toContain("data/boots-accounts.json");
|
|
229
|
+
expect(presignKeysFor("put")).not.toContain("data\\boots-accounts.json");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("primeUploads canonicalizes keys so the primed PUT URL matches the upload lookup", async () => {
|
|
233
|
+
// Prime then upload the SAME Windows-origin key. Both must key the cache
|
|
234
|
+
// under the POSIX form: prime signs the POSIX PUT URL, and uploadFile's
|
|
235
|
+
// hasPrimedPut(toPosixKey(...)) reuses it instead of re-presigning — proving
|
|
236
|
+
// the primed fast-path can't strand a backslash key (and can't store one).
|
|
237
|
+
await primeUploads(makeCtx(), [
|
|
238
|
+
{ key: "knowledge\\books-eoi.md", localPath: tmpFile, isSymlink: false },
|
|
239
|
+
]);
|
|
240
|
+
// prime("get") + prime("put") both went out under the POSIX key only.
|
|
241
|
+
expect(presignKeysFor("get")).toEqual(["knowledge/books-eoi.md"]);
|
|
242
|
+
expect(presignKeysFor("put")).toEqual(["knowledge/books-eoi.md"]);
|
|
243
|
+
|
|
244
|
+
const putCountAfterPrime = presignKeysFor("put").length;
|
|
245
|
+
await uploadFile(makeCtx(), tmpFile, "knowledge\\books-eoi.md");
|
|
246
|
+
// No NEW put presign: uploadFile reused the primed POSIX URL (cache hit).
|
|
247
|
+
expect(presignKeysFor("put").length).toBe(putCountAfterPrime);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
161
251
|
describe("uploadFile", () => {
|
|
162
252
|
let tmpFile: string;
|
|
163
253
|
|
package/src/s3.ts
CHANGED
|
@@ -339,9 +339,14 @@ export async function primeUploads(
|
|
|
339
339
|
if (!io.prime || items.length === 0) return;
|
|
340
340
|
|
|
341
341
|
// Prime GET first so each item's created-at HEAD reuses a cached URL.
|
|
342
|
+
// Canonicalize to POSIX here (one-canonical-form, matching the uploadFile /
|
|
343
|
+
// uploadSymlink boundary): a Windows-origin backslash key must cache under
|
|
344
|
+
// the SAME key uploadFile later looks up via hasPrimedPut(toPosixKey(...)),
|
|
345
|
+
// or the primed URL silently misses and the upload re-presigns. It also
|
|
346
|
+
// keeps the created-at HEAD pointed at the real (POSIX) object.
|
|
342
347
|
await io.prime(
|
|
343
348
|
"get",
|
|
344
|
-
items.map((i) => ({ key: i.key })),
|
|
349
|
+
items.map((i) => ({ key: toPosixKey(i.key) })),
|
|
345
350
|
);
|
|
346
351
|
|
|
347
352
|
// Build per-key PUT metadata with the SAME builders the upload path uses,
|
|
@@ -356,10 +361,15 @@ export async function primeUploads(
|
|
|
356
361
|
const worker = async (): Promise<void> => {
|
|
357
362
|
while (next < items.length) {
|
|
358
363
|
const it = items[next++];
|
|
359
|
-
|
|
364
|
+
// Same boundary guardrail as uploadFile/uploadSymlink: prime under the
|
|
365
|
+
// canonical POSIX key so the cached PUT URL is keyed identically to the
|
|
366
|
+
// hasPrimedPut/putObject lookup, and a backslash key can never be primed
|
|
367
|
+
// (let alone stored) as a non-POSIX vault key.
|
|
368
|
+
const key = toPosixKey(it.key);
|
|
369
|
+
const createdAt = await resolveCreatedAt(io, key, it.author);
|
|
360
370
|
if (it.isSymlink) {
|
|
361
371
|
putKeys.push({
|
|
362
|
-
key
|
|
372
|
+
key,
|
|
363
373
|
contentType: "application/octet-stream",
|
|
364
374
|
metadata: {
|
|
365
375
|
[SYMLINK_TARGET_META_KEY]: SYMLINK_MARKER_META_VALUE,
|
|
@@ -374,8 +384,8 @@ export async function primeUploads(
|
|
|
374
384
|
// raced rm / EPERM — leave stamps off (receiver umask default).
|
|
375
385
|
}
|
|
376
386
|
putKeys.push({
|
|
377
|
-
key
|
|
378
|
-
contentType: getMimeType(
|
|
387
|
+
key,
|
|
388
|
+
contentType: getMimeType(key),
|
|
379
389
|
metadata: {
|
|
380
390
|
...(it.author ? buildAuthorMetadata(it.author, createdAt) : {}),
|
|
381
391
|
...modeTime,
|
package/src/scope-shrink.test.ts
CHANGED
|
@@ -498,6 +498,52 @@ describe("applyScopeShrink", () => {
|
|
|
498
498
|
journal.files["companies/indigo/scratch/clean.md"]?.removedReason,
|
|
499
499
|
).toBe("narrow_apply");
|
|
500
500
|
});
|
|
501
|
+
|
|
502
|
+
// DEV-1768 fix #3: clean orphans are MOVED to quarantine (recoverable),
|
|
503
|
+
// not unlinked, when `cleanDisposition: "quarantine"`.
|
|
504
|
+
it("quarantines clean orphans (moves, not deletes) and reports named paths", () => {
|
|
505
|
+
const rel = "companies/indigo/scratch/clean.md";
|
|
506
|
+
const abs = path.join(hqRoot, rel);
|
|
507
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
508
|
+
fs.writeFileSync(abs, "clean");
|
|
509
|
+
const past = Date.now() - 60_000;
|
|
510
|
+
fs.utimesSync(abs, past / 1000, past / 1000);
|
|
511
|
+
|
|
512
|
+
const quarantineRoot = path.join(hqRoot, ".hq", "scope-quarantine", "indigo");
|
|
513
|
+
const journal: SyncJournal = {
|
|
514
|
+
...emptyJournal(),
|
|
515
|
+
files: {
|
|
516
|
+
[rel]: { hash: sha256("clean"), size: 5, syncedAt: new Date().toISOString(), direction: "down" },
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
const plan = buildScopeShrinkPlan({
|
|
520
|
+
journal,
|
|
521
|
+
hqRoot,
|
|
522
|
+
lastPrefixSet: ["companies/indigo/"],
|
|
523
|
+
currentPrefixSet: ["companies/indigo/meetings/"],
|
|
524
|
+
});
|
|
525
|
+
const result = applyScopeShrink({
|
|
526
|
+
journal,
|
|
527
|
+
plan,
|
|
528
|
+
hqRoot,
|
|
529
|
+
forceScopeShrink: false,
|
|
530
|
+
cleanDisposition: "quarantine",
|
|
531
|
+
quarantineRoot,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
expect(result.cleanQuarantined).toBe(1);
|
|
535
|
+
expect(result.cleanRemoved).toBe(0);
|
|
536
|
+
expect(result.quarantinedPaths).toEqual([rel]);
|
|
537
|
+
expect(result.removedPaths).toEqual([]);
|
|
538
|
+
expect(result.quarantineRoot).toBe(quarantineRoot);
|
|
539
|
+
// Moved, not deleted: gone from the working tree, present + intact in quarantine.
|
|
540
|
+
expect(fs.existsSync(abs)).toBe(false);
|
|
541
|
+
const quarantined = path.join(quarantineRoot, rel);
|
|
542
|
+
expect(fs.existsSync(quarantined)).toBe(true);
|
|
543
|
+
expect(fs.readFileSync(quarantined, "utf-8")).toBe("clean");
|
|
544
|
+
// Journal entry tombstoned so it stops being re-flagged (idempotent).
|
|
545
|
+
expect(journal.files[rel]?.removedAt).toBeTruthy();
|
|
546
|
+
});
|
|
501
547
|
});
|
|
502
548
|
|
|
503
549
|
describe("ScopeShrinkBlockedError", () => {
|
|
@@ -527,4 +573,29 @@ describe("ScopeShrinkBlockedError", () => {
|
|
|
527
573
|
expect(err.dirty).toHaveLength(1);
|
|
528
574
|
expect(err.name).toBe("ScopeShrinkBlockedError");
|
|
529
575
|
});
|
|
576
|
+
|
|
577
|
+
// DEV-1768 fix #2: advice must be FOLLOWABLE from the entry point that throws.
|
|
578
|
+
// The old message always said "pass --force-scope-shrink", which the menubar
|
|
579
|
+
// runner cannot accept.
|
|
580
|
+
const mk = (ctx?: "cli" | "runner" | "engine") =>
|
|
581
|
+
new ScopeShrinkBlockedError("cmp_x", "all", "shared", [], [], ctx);
|
|
582
|
+
|
|
583
|
+
it("runner-context advice points at a terminal command, NOT an impossible flag", () => {
|
|
584
|
+
const msg = mk("runner").message;
|
|
585
|
+
// The followable path from the menubar is a terminal command.
|
|
586
|
+
expect(msg).toContain("hq sync narrow --apply");
|
|
587
|
+
// It must NOT imply the flag is acceptable to the menubar itself.
|
|
588
|
+
expect(msg).toContain("menubar sync cannot take this flag");
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("cli-context advice offers the flag the CLI actually accepts", () => {
|
|
592
|
+
const msg = mk("cli").message;
|
|
593
|
+
expect(msg).toContain("--force-scope-shrink");
|
|
594
|
+
expect(msg).toContain("hq sync narrow --apply");
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("defaults to the engine/library advice when no context is given", () => {
|
|
598
|
+
expect(mk().adviceContext).toBe("engine");
|
|
599
|
+
expect(mk().message).toContain("hq sync narrow --apply");
|
|
600
|
+
});
|
|
530
601
|
});
|