Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/fix-clone-remote-did.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@enbox/gitd': patch
---

fix: skip local daemon when cloning repos owned by a different DID

The local daemon resolver now checks `ownerDid` in the lockfile and
only routes to `localhost` when the requested DID matches the daemon
owner. Previously, cloning any DID would hit the local daemon — which
does not have the remote user's repos — and fail with 404.
2 changes: 1 addition & 1 deletion src/cli/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ export async function serveCommand(ctx: AgentContext, args: string[]): Promise<v
const stopRepublisher = startDidRepublisher(ctx.enbox);

// Register the daemon so git-remote-did can discover it.
writeLockfile(server.port, getVersion() ?? undefined);
writeLockfile(server.port, getVersion() ?? undefined, ctx.did);

// Wire up the idle shutdown function now that we have all the pieces.
shutdown.fn = async (): Promise<void> => {
Expand Down
8 changes: 6 additions & 2 deletions src/daemon/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Daemon lockfile — discovery mechanism for the local gitd server.
*
* When `gitd serve` starts, it writes a JSON lockfile to
* `~/.enbox/daemon.lock` containing `{ pid, port, startedAt }`.
* `~/.enbox/daemon.lock` containing `{ pid, port, startedAt, ownerDid }`.
* `git-remote-did` reads this file to discover a running local daemon
* and resolve `did::` remotes to `http://localhost:<port>/...` instead
* of performing DID document resolution.
Expand Down Expand Up @@ -35,6 +35,9 @@ export type DaemonLock = {

/** The gitd version that started this daemon (for upgrade detection). */
version?: string;

/** The DID of the identity that owns this daemon. */
ownerDid?: string;
};

// ---------------------------------------------------------------------------
Expand All @@ -51,12 +54,13 @@ export function lockfilePath(): string {
// ---------------------------------------------------------------------------

/** Write the daemon lockfile. Overwrites any existing file. */
export function writeLockfile(port: number, version?: string): void {
export function writeLockfile(port: number, version?: string, ownerDid?: string): void {
const lock: DaemonLock = {
pid : process.pid,
port,
startedAt : new Date().toISOString(),
...(version ? { version } : {}),
...(ownerDid ? { ownerDid } : {}),
};
const path = lockfilePath();
mkdirSync(dirname(path), { recursive: true });
Expand Down
9 changes: 9 additions & 0 deletions src/git-remote/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ async function resolveLocalDaemon(did: string, repo?: string): Promise<GitEndpoi
// Fast path: check for an already-running daemon.
const lock = readLockfile();
if (lock) {
// Only use the local daemon when the requested DID matches the
// daemon's owner. Cloning someone else's repo must fall through
// to DID document resolution so the request reaches the correct
// remote server. Lockfiles without `ownerDid` (written by older
// versions) are treated as matching for backwards compatibility.
if (lock.ownerDid && lock.ownerDid !== did) {
return null;
}

const healthUrl = `http://localhost:${lock.port}/health`;
try {
const controller = new AbortController();
Expand Down
16 changes: 16 additions & 0 deletions tests/daemon-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ describe('lockfile version field', () => {
expect(lock!.version).toBeUndefined();
removeLockfile();
});

it('should write ownerDid to lockfile when provided', () => {
writeLockfile(9418, '1.0.0', 'did:dht:owner123');
const lock = readLockfile();
expect(lock).not.toBeNull();
expect(lock!.ownerDid).toBe('did:dht:owner123');
removeLockfile();
});

it('should omit ownerDid when not provided', () => {
writeLockfile(9418, '1.0.0');
const lock = readLockfile();
expect(lock).not.toBeNull();
expect(lock!.ownerDid).toBeUndefined();
removeLockfile();
});
});

// ---------------------------------------------------------------------------
Expand Down
47 changes: 46 additions & 1 deletion tests/git-remote.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ describe('resolveGitEndpoint with local daemon', () => {
server.close();
});

it('should resolve via local daemon when lockfile exists', async () => {
it('should resolve via local daemon when lockfile has no ownerDid (backwards compat)', async () => {
const result = await resolveGitEndpoint('did:dht:abc123', 'my-repo');
expect(result.source).toBe('LocalDaemon');
expect(result.url).toBe(`http://localhost:${port}/did:dht:abc123/my-repo`);
Expand All @@ -474,3 +474,48 @@ describe('resolveGitEndpoint with local daemon', () => {
expect(result.url).toBe(`http://localhost:${port}/did:dht:abc123`);
});
});

// ---------------------------------------------------------------------------
// Local daemon DID ownership check
// ---------------------------------------------------------------------------

describe('resolveGitEndpoint skips local daemon for non-owner DID', () => {
let server: ReturnType<typeof createServer>;
let port: number;

const ownerDid = 'did:dht:localowner';
const remoteDid = 'did:dht:remoteuser';

beforeAll(async () => {
server = createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
});
await new Promise<void>((resolve) => {
server.listen(0, () => {
port = (server.address() as any).port;
resolve();
});
});
// Write a lockfile with ownerDid set.
writeLockfile(port, '1.0.0', ownerDid);
});

afterAll(() => {
removeLockfile();
server.close();
});

it('should use local daemon when requested DID matches ownerDid', async () => {
const result = await resolveGitEndpoint(ownerDid, 'my-repo');
expect(result.source).toBe('LocalDaemon');
expect(result.url).toBe(`http://localhost:${port}/${ownerDid}/my-repo`);
});

it('should skip local daemon when requested DID differs from ownerDid', async () => {
// The remote DID is not resolvable, so this should throw after
// skipping the local daemon — confirming it didn't short-circuit.
await expect(resolveGitEndpoint(remoteDid, 'their-repo'))
.rejects.toThrow();
});
});
Loading