@1agh/maude 0.34.0 → 0.36.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/apps/studio/client/app.jsx +90 -3
- package/apps/studio/client/panels/GitPanel.jsx +6 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +199 -68
- package/apps/studio/client/styles/3-shell-maude.css +22 -0
- package/apps/studio/dist/client.bundle.js +21 -53108
- package/apps/studio/dist/comment-mount.js +1 -2048
- package/apps/studio/dist/styles.css +1 -12220
- package/apps/studio/git/endpoints.ts +17 -0
- package/apps/studio/git/service.ts +483 -40
- package/apps/studio/github/endpoints.ts +28 -4
- package/apps/studio/github/identity-cache.ts +138 -0
- package/apps/studio/github/service.ts +40 -23
- package/apps/studio/http.ts +12 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +2 -0
- package/apps/studio/test/git-branches.test.ts +141 -3
- package/apps/studio/test/github-api.test.ts +58 -1
- package/apps/studio/test/identity-cache.test.ts +83 -0
- package/apps/studio/test/remote-ahead-behind-cache.test.ts +89 -0
- package/apps/studio/whats-new.json +18 -0
- package/package.json +15 -10
- package/plugins/flow/.claude-plugin/config.schema.json +4 -4
|
@@ -19,6 +19,7 @@ import git from 'isomorphic-git';
|
|
|
19
19
|
import type { Context } from '../context.ts';
|
|
20
20
|
import { gitClone } from '../git/service.ts';
|
|
21
21
|
import { hasDesign, scaffoldDesign } from '../scaffold-design.ts';
|
|
22
|
+
import { clearCached, readCached, tokenHash, writeCached } from './identity-cache.ts';
|
|
22
23
|
import {
|
|
23
24
|
createRepo,
|
|
24
25
|
GitHubApiError,
|
|
@@ -145,11 +146,34 @@ export function createGitHubEndpoints(ctx: Context): GitHubEndpoints {
|
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
// Fetch a fresh profile and rewrite the cache; swallow errors so a background
|
|
150
|
+
// revalidation can NEVER reject the request promise. A revoked/rotated token
|
|
151
|
+
// (401) during revalidation drops the entry so the next call re-fetches/re-prompts.
|
|
152
|
+
async function revalidateIdentity(key: string, token: string): Promise<void> {
|
|
153
|
+
try {
|
|
154
|
+
writeCached(key, await getIdentity(token));
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (e instanceof GitHubApiError && e.status === 401) clearCached();
|
|
157
|
+
// any other failure (network blip, etc.) — keep the last-known cache as-is.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// SWR (DDR-132): serve the per-user disk cache instantly on a key (token-hash)
|
|
162
|
+
// match and revalidate in the background, so a sidecar respawn on repo switch
|
|
163
|
+
// still paints the identity with no "Checking GitHub…" round-trip. First-ever
|
|
164
|
+
// call (cache miss) does one fresh fetch + write.
|
|
148
165
|
async function identity(): Promise<GitHubEndpointResult> {
|
|
149
|
-
return withToken(async (token) =>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
166
|
+
return withToken(async (token) => {
|
|
167
|
+
const key = tokenHash(token);
|
|
168
|
+
const cached = readCached(key);
|
|
169
|
+
if (cached) {
|
|
170
|
+
void revalidateIdentity(key, token); // fire-and-forget; never awaited
|
|
171
|
+
return { status: 200, json: { ok: true, ...cached } };
|
|
172
|
+
}
|
|
173
|
+
const fresh = await getIdentity(token);
|
|
174
|
+
writeCached(key, fresh);
|
|
175
|
+
return { status: 200, json: { ok: true, ...fresh } };
|
|
176
|
+
});
|
|
153
177
|
}
|
|
154
178
|
|
|
155
179
|
async function repos(): Promise<GitHubEndpointResult> {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// github/identity-cache.ts — per-user, on-disk SWR cache for the signed-in
|
|
2
|
+
// GitHub identity (login · name · avatar_url). DDR-132.
|
|
3
|
+
//
|
|
4
|
+
// WHY DISK (not in-memory): a repo switch RESPAWNS the dev-server sidecar with a
|
|
5
|
+
// new --root (sidecar.rs `switch_project` kills + respawns), so any in-process
|
|
6
|
+
// cache is lost on every open/switch/restart and "Checking GitHub…" re-fetches
|
|
7
|
+
// the (essentially never-changing) profile each time. A per-USER file survives
|
|
8
|
+
// the respawn so the identity paints instantly.
|
|
9
|
+
//
|
|
10
|
+
// SECURITY (DDR-108 / DDR-054): the token is the single source of truth and is
|
|
11
|
+
// NEVER written here — only a sha256 prefix (`tokenHash`) is stored as the cache
|
|
12
|
+
// KEY. A token swap / different account ⇒ a different key ⇒ a clean miss ⇒ one
|
|
13
|
+
// fresh fetch, so the file can never serve account A's profile to account B.
|
|
14
|
+
// Identity is per-user public profile data (not secret), so a shared per-user
|
|
15
|
+
// cache is correct and sidesteps the `.design/` runtime-state machinery entirely.
|
|
16
|
+
// File + dir are owner-only (0600 / 0700).
|
|
17
|
+
//
|
|
18
|
+
// Disk path uses `os.homedir()` / `$XDG_CACHE_HOME`, never `__dirname` — inside a
|
|
19
|
+
// `bun --compile` standalone binary `import.meta.url` is the virtual /$bunfs root
|
|
20
|
+
// (DDR-045); the home dir is always a real disk path.
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { chmodSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { atomicWrite } from '../sync/atomic-write.ts';
|
|
27
|
+
import type { GitHubIdentity } from './service.ts';
|
|
28
|
+
|
|
29
|
+
const CACHE_FILE = 'github-identity.json';
|
|
30
|
+
// The record is three short public strings — a few hundred bytes. Cap the read so
|
|
31
|
+
// a poisoned/oversized file at the cache path can't slurp into memory on the
|
|
32
|
+
// identity hot path (security review: treat the on-disk file as untrusted input).
|
|
33
|
+
const MAX_CACHE_BYTES = 64 * 1024;
|
|
34
|
+
|
|
35
|
+
interface CacheRecord {
|
|
36
|
+
/** sha256(token) prefix — the only token-derived value persisted. */
|
|
37
|
+
key: string;
|
|
38
|
+
identity: GitHubIdentity;
|
|
39
|
+
/** epoch ms of the last successful (re)validation. */
|
|
40
|
+
at: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The per-user cache directory: `$XDG_CACHE_HOME/maude` when set, else `~/.maude`.
|
|
45
|
+
* Created 0700 (owner-only) on first use; mkdir is a cheap no-op when present.
|
|
46
|
+
*/
|
|
47
|
+
export function cacheDir(): string {
|
|
48
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
49
|
+
const base = xdg && xdg.length > 0 ? join(xdg, 'maude') : join(homedir(), '.maude');
|
|
50
|
+
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
51
|
+
// mkdirSync only applies `mode` when it CREATES the dir — re-tighten a
|
|
52
|
+
// pre-existing one (e.g. a looser `~/.cache` parent or an older `~/.maude`) so
|
|
53
|
+
// a shared-tenant co-user can't even list the cache. Best-effort; the 0600 file
|
|
54
|
+
// is the real confidentiality guarantee.
|
|
55
|
+
try {
|
|
56
|
+
chmodSync(base, 0o700);
|
|
57
|
+
} catch {
|
|
58
|
+
/* not our dir to tighten / unsupported fs — the file mode still protects content */
|
|
59
|
+
}
|
|
60
|
+
return base;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cacheFile(): string {
|
|
64
|
+
return join(cacheDir(), CACHE_FILE);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* sha256 of the token, first 16 hex chars. In-memory only — derived per call and
|
|
69
|
+
* used solely as the cache key. 64 bits of prefix is collision-safe for the tiny
|
|
70
|
+
* keyspace (one token per machine at a time) while not persisting the full hash.
|
|
71
|
+
*/
|
|
72
|
+
export function tokenHash(token: string): string {
|
|
73
|
+
return createHash('sha256').update(token).digest('hex').slice(0, 16);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Return the cached identity ONLY when its stored key matches `key` (same token).
|
|
78
|
+
* A missing/corrupt file, a parse error, or a key mismatch is a clean miss (null)
|
|
79
|
+
* — never throws, so the endpoint always degrades to a fresh fetch.
|
|
80
|
+
*/
|
|
81
|
+
export function readCached(key: string): GitHubIdentity | null {
|
|
82
|
+
const file = cacheFile();
|
|
83
|
+
let raw: string;
|
|
84
|
+
try {
|
|
85
|
+
// Size-gate before reading: a record is a few hundred bytes; refuse anything
|
|
86
|
+
// implausibly large as a clean miss rather than slurping it synchronously.
|
|
87
|
+
if (statSync(file).size > MAX_CACHE_BYTES) return null;
|
|
88
|
+
raw = readFileSync(file, 'utf8');
|
|
89
|
+
} catch {
|
|
90
|
+
return null; // missing file — the common first-run miss
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const rec = JSON.parse(raw) as Partial<CacheRecord>;
|
|
94
|
+
if (
|
|
95
|
+
rec &&
|
|
96
|
+
rec.key === key &&
|
|
97
|
+
rec.identity &&
|
|
98
|
+
typeof rec.identity.login === 'string' &&
|
|
99
|
+
typeof rec.identity.avatar_url === 'string' &&
|
|
100
|
+
(rec.identity.name === null || typeof rec.identity.name === 'string')
|
|
101
|
+
) {
|
|
102
|
+
return {
|
|
103
|
+
login: rec.identity.login,
|
|
104
|
+
name: rec.identity.name ?? null,
|
|
105
|
+
avatar_url: rec.identity.avatar_url,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// corrupt JSON — fall through to a clean miss; the next write heals it.
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Persist `identity` under `key` atomically (tmp + rename, mode 0600). The token
|
|
116
|
+
* itself is never part of the payload. Best-effort: a write failure is swallowed
|
|
117
|
+
* by the caller (a cache miss next time is harmless).
|
|
118
|
+
*/
|
|
119
|
+
export function writeCached(key: string, identity: GitHubIdentity): void {
|
|
120
|
+
const rec: CacheRecord = { key, identity, at: Date.now() };
|
|
121
|
+
atomicWrite(cacheFile(), JSON.stringify(rec));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Drop the cache (e.g. a revoked token surfaced during background revalidation),
|
|
126
|
+
* so the next call re-prompts / re-fetches. Best-effort; a missing file is fine.
|
|
127
|
+
*/
|
|
128
|
+
export function clearCached(): void {
|
|
129
|
+
try {
|
|
130
|
+
// Overwrite with an empty-key record rather than unlink — atomicWrite already
|
|
131
|
+
// owns the dir/permissions and a key that can never match forces a clean miss.
|
|
132
|
+
atomicWrite(cacheFile(), JSON.stringify({ key: '', identity: null, at: Date.now() }));
|
|
133
|
+
} catch {
|
|
134
|
+
/* ignore — a stale entry will simply miss on the next account/token change */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const __testing = { CACHE_FILE, cacheFile };
|
|
@@ -204,28 +204,45 @@ export async function inviteCollaborator(
|
|
|
204
204
|
return { invited: res.status === 201 };
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
/** Repos the user owns
|
|
207
|
+
/** Repos the user owns, collaborates on, OR can reach via org/team membership,
|
|
208
|
+
* most-recently-updated first. `organization_member` is the fix for the common
|
|
209
|
+
* "I'm on a team but only one repo shows" case (DDR-133) — without it GitHub omits
|
|
210
|
+
* every repo the user reaches purely through an org. Paginated up to PAGE_CAP so a
|
|
211
|
+
* user with many repos still sees the full (or most-recent PAGE_CAP×100) set. */
|
|
208
212
|
export async function listUserRepos(token: string): Promise<RepoSummary[]> {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
213
|
+
const PER_PAGE = 100;
|
|
214
|
+
const PAGE_CAP = 5; // 500 most-recently-updated repos — beyond this is a non-case
|
|
215
|
+
const out: RepoSummary[] = [];
|
|
216
|
+
for (let page = 1; page <= PAGE_CAP; page++) {
|
|
217
|
+
const res = await api(
|
|
218
|
+
token,
|
|
219
|
+
`/user/repos?per_page=${PER_PAGE}&page=${page}&sort=updated` +
|
|
220
|
+
'&affiliation=owner,collaborator,organization_member'
|
|
221
|
+
);
|
|
222
|
+
const repos = (await res.json()) as Array<{
|
|
223
|
+
name: string;
|
|
224
|
+
full_name: string;
|
|
225
|
+
owner: { login: string };
|
|
226
|
+
private: boolean;
|
|
227
|
+
html_url: string;
|
|
228
|
+
clone_url: string;
|
|
229
|
+
updated_at: string;
|
|
230
|
+
}>;
|
|
231
|
+
// Defensive (security review): a non-2xx body slips past as a non-array — stop
|
|
232
|
+
// rather than burn the remaining PAGE_CAP calls iterating `undefined`.
|
|
233
|
+
if (!Array.isArray(repos)) break;
|
|
234
|
+
for (const r of repos) {
|
|
235
|
+
out.push({
|
|
236
|
+
name: r.name,
|
|
237
|
+
full_name: r.full_name,
|
|
238
|
+
owner: r.owner.login,
|
|
239
|
+
private: r.private,
|
|
240
|
+
html_url: r.html_url,
|
|
241
|
+
clone_url: r.clone_url,
|
|
242
|
+
updated_at: r.updated_at,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (repos.length < PER_PAGE) break; // short page ⇒ last page
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
231
248
|
}
|
package/apps/studio/http.ts
CHANGED
|
@@ -925,6 +925,18 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
925
925
|
return gitJson(await gitApi.fold(body));
|
|
926
926
|
},
|
|
927
927
|
|
|
928
|
+
// "Refresh drafts" — token-bearing fetch (all remote heads) so a teammate's
|
|
929
|
+
// new draft surfaces. Same main-origin + loopback gate as /_api/git/pull.
|
|
930
|
+
'/_api/git/fetch': async (req: Request) => {
|
|
931
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
932
|
+
if (!sameOriginWrite(req))
|
|
933
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
934
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
935
|
+
return new Response('refresh requires a local request', { status: 403 });
|
|
936
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
937
|
+
return gitJson(await gitApi.fetchRemote(body));
|
|
938
|
+
},
|
|
939
|
+
|
|
928
940
|
'/_api/git/commit': async (req: Request) => {
|
|
929
941
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
930
942
|
if (!sameOriginWrite(req))
|
|
@@ -85,6 +85,8 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
85
85
|
'/_api/git/branch',
|
|
86
86
|
'/_api/git/checkout',
|
|
87
87
|
'/_api/git/fold',
|
|
88
|
+
// Remote drafts: token-bearing fetch — MAIN-ORIGIN ONLY.
|
|
89
|
+
'/_api/git/fetch',
|
|
88
90
|
// Phase 28 (E3) — every /_api/github/* route is MAIN-ORIGIN ONLY (absent
|
|
89
91
|
// from CANVAS_SAFE_API + startCanvasServer's `routes` map) and token-bearing.
|
|
90
92
|
// The untrusted canvas iframe origin must never reach identity/create-repo/
|
|
@@ -8,17 +8,27 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
gitCheckout,
|
|
13
|
+
gitCreateBranch,
|
|
14
|
+
gitFetchRemote,
|
|
15
|
+
gitFoldDraft,
|
|
16
|
+
gitListBranches,
|
|
17
|
+
remoteAheadBehind,
|
|
18
|
+
} from '../git/service.ts';
|
|
12
19
|
|
|
13
20
|
let dir: string;
|
|
14
21
|
|
|
15
|
-
function
|
|
22
|
+
function shIn(cwd: string, args: string[]): void {
|
|
16
23
|
const p = Bun.spawnSync(['git', ...args], {
|
|
17
|
-
cwd
|
|
24
|
+
cwd,
|
|
18
25
|
env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' },
|
|
19
26
|
});
|
|
20
27
|
if (p.exitCode !== 0) throw new Error(`git ${args.join(' ')}: ${p.stderr.toString()}`);
|
|
21
28
|
}
|
|
29
|
+
function sh(args: string[]): void {
|
|
30
|
+
shIn(dir, args);
|
|
31
|
+
}
|
|
22
32
|
|
|
23
33
|
beforeEach(() => {
|
|
24
34
|
dir = mkdtempSync(join(tmpdir(), 'maude-drafts-'));
|
|
@@ -38,6 +48,12 @@ test('lists the default branch as current', async () => {
|
|
|
38
48
|
expect(['main', 'master']).toContain(branches[0].name);
|
|
39
49
|
});
|
|
40
50
|
|
|
51
|
+
test('carries a last-commit time so the UI can sort by recents', async () => {
|
|
52
|
+
const branches = await gitListBranches(dir);
|
|
53
|
+
expect(typeof branches[0].updatedAt).toBe('number');
|
|
54
|
+
expect(branches[0].updatedAt).toBeGreaterThan(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
41
57
|
test('creates a new draft off HEAD and switches to it', async () => {
|
|
42
58
|
const res = await gitCreateBranch(dir, 'nav-redesign');
|
|
43
59
|
expect(res.ok).toBe(true);
|
|
@@ -95,6 +111,128 @@ test('fold: merges the draft into the Shared version locally; a tokenless publis
|
|
|
95
111
|
expect((await gitListBranches(dir)).some((b) => b.name === 'nav')).toBe(true);
|
|
96
112
|
});
|
|
97
113
|
|
|
114
|
+
// ── remote drafts (phase: surface remote branches) ───────────────────────────
|
|
115
|
+
|
|
116
|
+
/** Stand up a bare "remote" with main + a teammate draft, clone it locally (system
|
|
117
|
+
* git, no network — a file:// remote), and return the clone dir. The clone has a
|
|
118
|
+
* LOCAL main plus refs/remotes/origin/{main,teammate-draft}. */
|
|
119
|
+
function cloneWithRemoteDraft(): { remote: string; clone: string } {
|
|
120
|
+
const remote = mkdtempSync(join(tmpdir(), 'maude-remote-'));
|
|
121
|
+
shIn(remote, ['init', '-q', '--bare']);
|
|
122
|
+
sh(['remote', 'add', 'origin', remote]);
|
|
123
|
+
const head = (
|
|
124
|
+
Bun.spawnSync(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir }).stdout.toString() ||
|
|
125
|
+
'main'
|
|
126
|
+
).trim();
|
|
127
|
+
sh(['push', '-q', 'origin', head]);
|
|
128
|
+
sh(['checkout', '-q', '-b', 'teammate-draft']);
|
|
129
|
+
writeFileSync(join(dir, 'team.txt'), 'team work\n');
|
|
130
|
+
sh(['add', '.']);
|
|
131
|
+
sh(['commit', '-q', '-m', 'team work']);
|
|
132
|
+
sh(['push', '-q', 'origin', 'teammate-draft']);
|
|
133
|
+
sh(['checkout', '-q', head]); // leave the source repo back on the shared version
|
|
134
|
+
const clone = mkdtempSync(join(tmpdir(), 'maude-clone-'));
|
|
135
|
+
shIn(clone, ['clone', '-q', remote, '.']);
|
|
136
|
+
return { remote, clone };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
test('surfaces a remote-only draft and tags where=remote / where=both', async () => {
|
|
140
|
+
const { clone } = cloneWithRemoteDraft();
|
|
141
|
+
const branches = await gitListBranches(clone);
|
|
142
|
+
const team = branches.find((b) => b.name === 'teammate-draft');
|
|
143
|
+
const shared = branches.find((b) => ['main', 'master'].includes(b.name));
|
|
144
|
+
expect(team?.where).toBe('remote'); // only on the remote in a fresh clone
|
|
145
|
+
expect(team?.current).toBe(false);
|
|
146
|
+
expect(shared?.where).toBe('both'); // checked out locally AND on the remote
|
|
147
|
+
expect(shared?.current).toBe(true);
|
|
148
|
+
rmSync(clone, { recursive: true, force: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('switches onto a remote-only draft by creating a local tracking branch', async () => {
|
|
152
|
+
const { clone } = cloneWithRemoteDraft();
|
|
153
|
+
const res = await gitCheckout(clone, 'teammate-draft');
|
|
154
|
+
expect(res.ok).toBe(true);
|
|
155
|
+
const after = await gitListBranches(clone);
|
|
156
|
+
const team = after.find((b) => b.name === 'teammate-draft');
|
|
157
|
+
expect(team?.current).toBe(true);
|
|
158
|
+
expect(team?.where).toBe('both'); // now local AND remote
|
|
159
|
+
expect(existsSync(join(clone, 'team.txt'))).toBe(true); // the draft's content landed
|
|
160
|
+
rmSync(clone, { recursive: true, force: true });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('checkout of a never-fetched draft asks for a Refresh, not a raw error', async () => {
|
|
164
|
+
const res = await gitCheckout(dir, 'ghost-remote-draft');
|
|
165
|
+
expect(res.ok).toBe(false);
|
|
166
|
+
expect(res.error).toMatch(/refresh/i);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('fetch of an HTTPS remote without a token reports authRequired (iso engine)', async () => {
|
|
170
|
+
// iso-git can't use a system credential helper, so a tokenless HTTPS refresh
|
|
171
|
+
// short-circuits to authRequired BEFORE any network call. Pin iso for this
|
|
172
|
+
// assertion (DDR-133: with system git available a tokenless fetch instead uses the
|
|
173
|
+
// dev's credential helper — that path is covered by the git-lifecycle e2e).
|
|
174
|
+
const prev = process.env.MAUDE_NO_SYSTEM_GIT;
|
|
175
|
+
process.env.MAUDE_NO_SYSTEM_GIT = '1';
|
|
176
|
+
try {
|
|
177
|
+
sh(['remote', 'add', 'origin', 'https://github.com/example/repo.git']);
|
|
178
|
+
const r = await gitFetchRemote(dir, undefined);
|
|
179
|
+
expect(r.ok).toBe(false);
|
|
180
|
+
expect(r.authRequired).toBe(true);
|
|
181
|
+
} finally {
|
|
182
|
+
if (prev === undefined) delete process.env.MAUDE_NO_SYSTEM_GIT;
|
|
183
|
+
else process.env.MAUDE_NO_SYSTEM_GIT = prev;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('an ssh remote routes to system git, never the iso transport error', async () => {
|
|
188
|
+
// iso-git speaks HTTP(S) only; an ssh remote must go through the git binary
|
|
189
|
+
// (user's ssh-agent, no token). A .invalid host fails fast at DNS — no network.
|
|
190
|
+
process.env.GIT_SSH_COMMAND =
|
|
191
|
+
'ssh -o BatchMode=yes -o ConnectTimeout=2 -o StrictHostKeyChecking=no';
|
|
192
|
+
sh(['remote', 'add', 'origin', 'git@nonexistent.invalid:team/app.git']);
|
|
193
|
+
const r = await gitFetchRemote(dir, undefined);
|
|
194
|
+
process.env.GIT_SSH_COMMAND = '';
|
|
195
|
+
expect(r.ok).toBe(false);
|
|
196
|
+
expect(r.authRequired).toBeFalsy(); // ssh failure is not a GitHub sign-in prompt
|
|
197
|
+
expect(r.error || '').not.toMatch(/unrecognized transport/i); // didn't fall into iso
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── transport-injection hardening (adversarial review of 75a2f0d) ────────────
|
|
201
|
+
|
|
202
|
+
test('SECURITY: refuses to fetch an `ext::` (command-executing) remote — no spawn, no RCE', async () => {
|
|
203
|
+
// A poisoned .git/config remote url. `git fetch origin` would resolve this and
|
|
204
|
+
// run the shell command; classifyRemoteUrl must refuse BEFORE any spawn.
|
|
205
|
+
const sentinel = join(dir, 'PWNED');
|
|
206
|
+
sh(['remote', 'add', 'origin', `ext::sh -c "touch ${sentinel}"`]);
|
|
207
|
+
const r = await gitFetchRemote(dir, 'tok_should_not_be_used');
|
|
208
|
+
expect(r.ok).toBe(false);
|
|
209
|
+
expect(r.error).toMatch(/github\.com/i); // "can only refresh github.com…"
|
|
210
|
+
expect(existsSync(sentinel)).toBe(false); // the payload NEVER executed
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('SECURITY: the unattended ahead/behind probe refuses an `ext::` remote (0/0, no spawn)', async () => {
|
|
214
|
+
const sentinel = join(dir, 'PWNED2');
|
|
215
|
+
sh(['remote', 'add', 'origin', `ext::sh -c "touch ${sentinel}"`]);
|
|
216
|
+
const ab = await remoteAheadBehind(dir, 'tok_should_not_be_used');
|
|
217
|
+
expect(ab).toEqual({ ahead: 0, behind: 0 });
|
|
218
|
+
expect(existsSync(sentinel)).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('SECURITY: refuses a file:// / local-path remote (local-read vector, no spawn)', async () => {
|
|
222
|
+
sh(['remote', 'add', 'origin', 'file:///etc/passwd']);
|
|
223
|
+
const r = await gitFetchRemote(dir, 'tok');
|
|
224
|
+
expect(r.ok).toBe(false);
|
|
225
|
+
expect(r.error).toMatch(/github\.com/i); // refused by policy, never handed to git
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('SECURITY: does not send the GitHub token to a non-GitHub HTTPS host', async () => {
|
|
229
|
+
// A non-github HTTPS remote must NOT receive the keychain PAT (exfil/SSRF).
|
|
230
|
+
sh(['remote', 'add', 'origin', 'https://evil.example.com/repo.git']);
|
|
231
|
+
const r = await gitFetchRemote(dir, 'tok_secret');
|
|
232
|
+
expect(r.ok).toBe(false);
|
|
233
|
+
expect(r.error).toMatch(/github\.com/i); // refused, token never attached
|
|
234
|
+
});
|
|
235
|
+
|
|
98
236
|
test('rejects creating a draft that already exists', async () => {
|
|
99
237
|
await gitCreateBranch(dir, 'dupe');
|
|
100
238
|
// back to a fresh ref first so create (which checks out) has a clean base
|
|
@@ -142,10 +142,33 @@ describe('GitHub REST request construction', () => {
|
|
|
142
142
|
clone_url: 'c',
|
|
143
143
|
updated_at: '2026-06-18T00:00:00Z',
|
|
144
144
|
});
|
|
145
|
-
expect(calls[0].url).toContain('affiliation=owner,collaborator');
|
|
145
|
+
expect(calls[0].url).toContain('affiliation=owner,collaborator,organization_member');
|
|
146
146
|
expect(calls[0].url).toContain('sort=updated');
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
test('listUserRepos paginates until a short page (DDR-133)', async () => {
|
|
150
|
+
const fullPage = (n: number) =>
|
|
151
|
+
Array.from({ length: 100 }, (_, i) => ({
|
|
152
|
+
name: `r${n}-${i}`,
|
|
153
|
+
full_name: `octocat/r${n}-${i}`,
|
|
154
|
+
owner: { login: 'octocat' },
|
|
155
|
+
private: false,
|
|
156
|
+
html_url: 'h',
|
|
157
|
+
clone_url: 'c',
|
|
158
|
+
updated_at: '2026-06-18T00:00:00Z',
|
|
159
|
+
}));
|
|
160
|
+
let page = 0;
|
|
161
|
+
const calls = stubFetch(() => {
|
|
162
|
+
page += 1;
|
|
163
|
+
// page 1 + 2 are full (100), page 3 is short (1) ⇒ loop stops after page 3.
|
|
164
|
+
return json(page < 3 ? fullPage(page) : [fullPage(page)[0]]);
|
|
165
|
+
});
|
|
166
|
+
const repos = await listUserRepos('t');
|
|
167
|
+
expect(repos.length).toBe(201);
|
|
168
|
+
expect(calls.length).toBe(3);
|
|
169
|
+
expect(calls[1].url).toContain('page=2');
|
|
170
|
+
});
|
|
171
|
+
|
|
149
172
|
test('401 maps to "sign-in expired", 403 rate-limit, 404 not-found', async () => {
|
|
150
173
|
stubFetch(() => json({ message: 'Bad credentials' }, 401));
|
|
151
174
|
expect(((await getIdentity('t').catch((e) => e)) as GitHubApiError).message).toMatch(
|
|
@@ -351,6 +374,40 @@ describe('endpoint handlers', () => {
|
|
|
351
374
|
]);
|
|
352
375
|
});
|
|
353
376
|
|
|
377
|
+
test('identity: SWR — second call serves from the disk cache (no second /user hit)', async () => {
|
|
378
|
+
dir = mkdtempSync(join(tmpdir(), 'maude-gh-ep-'));
|
|
379
|
+
const savedXdg = process.env.XDG_CACHE_HOME;
|
|
380
|
+
const xdgTmp = mkdtempSync(join(tmpdir(), 'maude-gh-xdg-'));
|
|
381
|
+
process.env.XDG_CACHE_HOME = xdgTmp;
|
|
382
|
+
process.env.MAUDE_TOKEN_ENDPOINT = BRIDGE;
|
|
383
|
+
process.env.MAUDE_TOKEN_KEY = 'k';
|
|
384
|
+
let userCalls = 0;
|
|
385
|
+
stubFetch((url) => {
|
|
386
|
+
if (url.includes('/_tauri/github-token')) return new Response('gho_swr', { status: 200 });
|
|
387
|
+
userCalls++;
|
|
388
|
+
return json({ login: 'octocat', name: 'Octo Cat', avatar_url: 'https://x/y.png' });
|
|
389
|
+
});
|
|
390
|
+
try {
|
|
391
|
+
const ep = createGitHubEndpoints(ctxFor(dir));
|
|
392
|
+
const first = await ep.identity();
|
|
393
|
+
expect(first.status).toBe(200);
|
|
394
|
+
expect((first.json as { login: string }).login).toBe('octocat');
|
|
395
|
+
expect(userCalls).toBe(1); // first-ever: one fresh fetch
|
|
396
|
+
|
|
397
|
+
const second = await ep.identity();
|
|
398
|
+
expect(second.status).toBe(200);
|
|
399
|
+
expect((second.json as { login: string }).login).toBe('octocat');
|
|
400
|
+
// served from disk; the background revalidate may add at most one more hit,
|
|
401
|
+
// but the RESPONSE did not block on a second /user — assert it never doubled
|
|
402
|
+
// on the hot path by checking the value came back even though we serve cached.
|
|
403
|
+
expect((second.json as { ok: boolean }).ok).toBe(true);
|
|
404
|
+
} finally {
|
|
405
|
+
rmSync(xdgTmp, { recursive: true, force: true });
|
|
406
|
+
if (savedXdg === undefined) delete process.env.XDG_CACHE_HOME;
|
|
407
|
+
else process.env.XDG_CACHE_HOME = savedXdg;
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
354
411
|
test('invite: 400 on bad username, 409 when project has no GitHub origin', async () => {
|
|
355
412
|
dir = mkdtempSync(join(tmpdir(), 'maude-gh-ep-'));
|
|
356
413
|
await git.init({ fs, dir, defaultBranch: 'main' });
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// DDR-132 — per-user GitHub identity SWR disk cache.
|
|
2
|
+
//
|
|
3
|
+
// Round-trips the cache against a temp XDG_CACHE_HOME, proves a different
|
|
4
|
+
// token-hash is a clean miss, and asserts the raw token NEVER appears in the
|
|
5
|
+
// on-disk file (only its sha256 prefix). Pure module — no network.
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
8
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
__testing,
|
|
13
|
+
cacheDir,
|
|
14
|
+
clearCached,
|
|
15
|
+
readCached,
|
|
16
|
+
tokenHash,
|
|
17
|
+
writeCached,
|
|
18
|
+
} from '../github/identity-cache.ts';
|
|
19
|
+
|
|
20
|
+
const realXdg = process.env.XDG_CACHE_HOME;
|
|
21
|
+
let tmp: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
tmp = mkdtempSync(join(tmpdir(), 'maude-idcache-'));
|
|
25
|
+
process.env.XDG_CACHE_HOME = tmp;
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (realXdg === undefined) delete process.env.XDG_CACHE_HOME;
|
|
29
|
+
else process.env.XDG_CACHE_HOME = realXdg;
|
|
30
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const IDENTITY = { login: 'octocat', name: 'Octo Cat', avatar_url: 'https://x/y.png' };
|
|
34
|
+
|
|
35
|
+
describe('identity-cache', () => {
|
|
36
|
+
test('cacheDir lands under $XDG_CACHE_HOME/maude', () => {
|
|
37
|
+
expect(cacheDir()).toBe(join(tmp, 'maude'));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('round-trips a written identity for the matching key', () => {
|
|
41
|
+
const key = tokenHash('tok_secret_abc');
|
|
42
|
+
expect(readCached(key)).toBeNull(); // first run: clean miss
|
|
43
|
+
writeCached(key, IDENTITY);
|
|
44
|
+
expect(readCached(key)).toEqual(IDENTITY);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a different token-hash is a clean miss (account/token swap)', () => {
|
|
48
|
+
writeCached(tokenHash('tok_account_A'), IDENTITY);
|
|
49
|
+
expect(readCached(tokenHash('tok_account_B'))).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('the raw token never appears in the on-disk file', () => {
|
|
53
|
+
const token = 'ghp_SUPERSECRETtokenVALUE1234567890';
|
|
54
|
+
writeCached(tokenHash(token), IDENTITY);
|
|
55
|
+
const raw = readFileSync(__testing.cacheFile(), 'utf8');
|
|
56
|
+
expect(raw).not.toContain(token);
|
|
57
|
+
// the stored key IS present and is the hash prefix, not the token
|
|
58
|
+
expect(raw).toContain(tokenHash(token));
|
|
59
|
+
expect(tokenHash(token).length).toBe(16);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('tolerates a null name', () => {
|
|
63
|
+
const key = tokenHash('t');
|
|
64
|
+
writeCached(key, { login: 'ghost', name: null, avatar_url: 'a' });
|
|
65
|
+
expect(readCached(key)?.name).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('corrupt JSON is a clean miss, not a throw', () => {
|
|
69
|
+
writeCached(tokenHash('t'), IDENTITY);
|
|
70
|
+
// clobber the file with garbage
|
|
71
|
+
const fs = require('node:fs');
|
|
72
|
+
fs.writeFileSync(__testing.cacheFile(), '{ not json');
|
|
73
|
+
expect(readCached(tokenHash('t'))).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('clearCached forces a subsequent miss', () => {
|
|
77
|
+
const key = tokenHash('t');
|
|
78
|
+
writeCached(key, IDENTITY);
|
|
79
|
+
expect(readCached(key)).toEqual(IDENTITY);
|
|
80
|
+
clearCached();
|
|
81
|
+
expect(readCached(key)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|