Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ee40bc8
feat(cli): sandbox.trust 'workspace' joins the schema, refused everyw…
lilyshen0722 Oct 25, 2026
3ddbf1e
feat(cli): read-only git as the workspace seat's one git tool, argv-v…
lilyshen0722 Oct 25, 2026
82c04fe
feat(cli): a first-run claude seat in private pods reads the dir it s…
lilyshen0722 Oct 25, 2026
887704b
feat(cli): a workspace seat re-reads its pods before every turn (TASK…
lilyshen0722 Oct 25, 2026
a6ba3b8
fix(cli): bind a workspace seat's session to where its turn runs (TAS…
lilyshen0722 Oct 25, 2026
8cf20fb
feat(cli): the claude adapter confines a workspace seat (TASK-294)
lilyshen0722 Oct 25, 2026
5e8589b
feat(cli): a workspace seat reads its repo's history through a read-o…
lilyshen0722 Oct 25, 2026
64c2353
test(cli): pin that a workspace seat is not taught the row-working ha…
lilyshen0722 Oct 25, 2026
69e471d
feat(cli): a workspace seat's write and web grants widen its jail and…
lilyshen0722 Oct 25, 2026
b15a64b
fix(cli): hold .env files out of a confined seat's reach at any depth…
lilyshen0722 Oct 25, 2026
60569be
feat(cli): run --allow sets a first-run workspace seat's grants (TASK…
lilyshen0722 Oct 25, 2026
0a66fff
fix(cli): off macOS a first-run seat keeps the public default (TASK-294)
lilyshen0722 Oct 25, 2026
09f71a6
docs(byo): what a Claude Code listener can reach, and --allow (TASK-294)
lilyshen0722 Oct 25, 2026
bc893b2
feat(frontend): the Connect page says what a Claude Code listener can…
lilyshen0722 Oct 25, 2026
0dc292b
fix(cli): read-only git refuses blame -S, which reads a caller-named …
lilyshen0722 Oct 25, 2026
f69653e
fix(frontend): the Connect page no longer says write keeps a listener…
lilyshen0722 Oct 25, 2026
59d2d90
fix(cli): workspace trust needs an invite-only pod, not only an unlis…
lilyshen0722 Oct 25, 2026
4b55499
fix(cli): read-only git keeps committed .env files out of reach (TASK…
lilyshen0722 Oct 25, 2026
a190a3c
fix(cli): workspace git runs no gpg or textconv; .env dirs and .git s…
lilyshen0722 Oct 25, 2026
9bf2758
fix(cli): a turn that runs public gets no memory and no memory tools …
lilyshen0722 Oct 25, 2026
0ecbe86
docs(byo): the folder needs macOS, and git can't refuse a .env asked …
lilyshen0722 Oct 25, 2026
117aa10
fix(cli): read-only git refuses --follow, which the .env excludes alw…
lilyshen0722 Oct 25, 2026
213d5aa
fix(cli): read-only git holds a submodule to the same rules (TASK-294)
lilyshen0722 Oct 25, 2026
e05b9d4
fix(byo): the Connect page says the folder default needs macOS (TASK-…
lilyshen0722 Oct 25, 2026
cebf403
fix(cli): read-only git never contacts a remote, even in a partial cl…
lilyshen0722 Oct 25, 2026
3ce8e85
docs(byo): anyone in the pods can ask a listener what its folder hold…
lilyshen0722 Oct 25, 2026
b28e35e
fix(cli): read-only git refuses --full-diff, which shows .env past th…
lilyshen0722 Oct 25, 2026
488c64a
test(cli): test git inherits no GIT_* from the shell (TASK-294)
lilyshen0722 Oct 25, 2026
97f5393
fix(frontend): the Connect page states every condition for folder acc…
lilyshen0722 Oct 25, 2026
a0c3720
docs(byo): the scratch-folder cases include a pod listed in Community…
lilyshen0722 Oct 25, 2026
bb7c6ca
fix(cli): read-only git takes -L only where git is sure to read it as…
lilyshen0722 Oct 26, 2026
885e8bf
test(byo): a Linux Claude Code setup shows no folder guidance (TASK-294)
lilyshen0722 Oct 26, 2026
ae10303
fix(connect): the zh-CN folder-access line follows the zh-CN glossary…
lilyshen0722 Oct 26, 2026
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
81 changes: 79 additions & 2 deletions backend/__tests__/unit/routes/agentsRuntime.installations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,28 @@ const mockInstallation = {
config: { runtime: { runtimeType: 'webhook', adapter: 'opencode', model: 'openai/gpt-5.4' } },
};

let mockAuthorizedPodIds = ['pod-1'];
let mockPods = [{ _id: 'pod-1', name: 'Connector room', type: 'team' }];

jest.mock('../../../middleware/agentRuntimeAuth', () => (req, _res, next) => {
req.agentUser = { _id: 'agent-1', username: 'writer' };
req.agentInstallation = mockInstallation;
req.agentInstallations = [mockInstallation];
req.agentAuthorizedPodIds = ['pod-1'];
req.agentAuthorizedPodIds = mockAuthorizedPodIds;
next();
});
jest.mock('../../../middleware/auth', () => (_req, _res, next) => next());
jest.mock('../../../middleware/apiTokenScopes', () => ({
requireApiTokenScopes: () => (_req, _res, next) => next(),
}));
// Honours the select list, so a field the route forgets to select reads as absent.
jest.mock('../../../models/Pod', () => ({
find: jest.fn(() => ({
select: () => ({ lean: async () => [{ _id: 'pod-1', name: 'Connector room', type: 'team' }] }),
select: (fields) => ({
lean: async () => mockPods.map((pod) => Object.fromEntries(
Object.entries(pod).filter(([key]) => fields.split(' ').includes(key)),
)),
}),
})),
}));
jest.mock('../../../services/agentEventService', () => ({}));
Expand Down Expand Up @@ -103,3 +111,72 @@ describe('GET /installations runtime adapter projection', () => {
}
});
});

// A `workspace`-trust CLI seat runs a turn with its start directory readable only
// when every room it answers in is private (TASK-294), and this list is where it
// learns that.
describe('GET /installations pod visibility', () => {
const withPods = async (pods, authorized, run) => {
const [savedPods, savedAuthorized] = [mockPods, mockAuthorizedPodIds];
mockPods = pods;
mockAuthorizedPodIds = authorized;
try {
await run();
} finally {
mockPods = savedPods;
mockAuthorizedPodIds = savedAuthorized;
}
};

test('a pod with no stored visibility is reported private, as the effective booleans', async () => {
await withPods([{ _id: 'pod-1', name: 'Room', type: 'team' }], ['pod-1'], async () => {
const response = await request(app).get('/api/agents/runtime/installations');
expect(response.status).toBe(200);
expect(response.body.installations[0]).toMatchObject({ publicRead: false, communityListed: false });
});
});

// An open pod an agent created admits any agent through self-install, listed
// or not, so the CLI needs the join policy as well as the visibility.
test('the join policy is reported, and a pod with none stored reads as open', async () => {
await withPods([
{ _id: 'pod-1', name: 'Private', type: 'team', joinPolicy: 'invite-only' },
{ _id: 'pod-2', name: 'Open', type: 'team', joinPolicy: 'open' },
{ _id: 'pod-3', name: 'Legacy', type: 'team' },
], ['pod-1', 'pod-2', 'pod-3'], async () => {
const response = await request(app).get('/api/agents/runtime/installations');
const policy = Object.fromEntries(response.body.installations
.map((entry) => [entry.podId, entry.joinPolicy]));
expect(policy).toEqual({ 'pod-1': 'invite-only', 'pod-2': 'open', 'pod-3': 'open' });
});
});

test('a publicly readable or listed pod is reported as such', async () => {
await withPods([{
_id: 'pod-1', name: 'Room', type: 'team', publicRead: true, communityListed: true,
}], ['pod-1'], async () => {
const response = await request(app).get('/api/agents/runtime/installations');
expect(response.body.installations[0]).toMatchObject({ publicRead: true, communityListed: true });
});
});

test('a pod that no longer exists carries neither field, so the CLI cannot read it as private', async () => {
await withPods([], ['pod-1'], async () => {
const response = await request(app).get('/api/agents/runtime/installations');
expect(response.body.installations[0]).not.toHaveProperty('publicRead');
expect(response.body.installations[0]).not.toHaveProperty('communityListed');
expect(response.body.installations[0]).not.toHaveProperty('joinPolicy');
});
});

test('DM pods the seat answers in carry the same fields', async () => {
await withPods([
{ _id: 'pod-1', name: 'Room', type: 'team' },
{ _id: 'dm-1', name: 'DM', type: 'agent-room', publicRead: false, communityListed: false },
], ['pod-1', 'dm-1'], async () => {
const response = await request(app).get('/api/agents/runtime/installations');
const dm = response.body.installations.find((entry) => entry.podId === 'dm-1');
expect(dm).toMatchObject({ type: 'dm', publicRead: false, communityListed: false });
});
});
});
21 changes: 20 additions & 1 deletion backend/routes/agentsRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,11 +539,28 @@ router.get('/installations', phase4RateLimit, agentRuntimeAuth, async (req: any,
const allPodIds = [...podIds.map(String), ...dmPodIds.map(String)];

const pods = allPodIds.length > 0
? await Pod.find({ _id: { $in: allPodIds } }).select('_id name type').lean()
? await Pod.find({ _id: { $in: allPodIds } }).select('_id name type publicRead communityListed joinPolicy').lean()
: [];

const podMap = Object.fromEntries(pods.map((p: any) => [String(p._id), p]));

// Whether strangers can read or join each room this seat answers in
// (TASK-294). A `workspace`-trust CLI seat reads it before every turn and
// runs that turn public unless every pod here is private and invite-only,
// so the effective values are reported: an absent stored visibility field
// means false, as everywhere else that reads `publicRead || communityListed`,
// and an absent joinPolicy means open, as in podListing.ts. Visibility
// alone is not enough: POST /pods/:podId/self-install admits any agent to
// an open pod an agent created, whether or not it is listed. A pod that no
// longer exists gets none of the fields, which the CLI reads as not-private.
const podVisibility = (pod: any) => (pod?._id
? {
publicRead: pod.publicRead === true,
communityListed: pod.communityListed === true,
joinPolicy: pod.joinPolicy === 'invite-only' ? 'invite-only' : 'open',
}
: {});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const installationList = installations.map((inst: any) => {
const pod = podMap[String(inst?.podId)] || {};
return {
Expand All @@ -557,6 +574,7 @@ router.get('/installations', phase4RateLimit, agentRuntimeAuth, async (req: any,
// Known runtime kinds (such as a connect-page `webhook`) stay null.
// Expose only this non-secret discriminator, never the full config.
runtimeAdapter: adapterForRuntimeBinding(inst?.config?.runtime),
...podVisibility(pod),
type: 'installation',
};
});
Expand All @@ -569,6 +587,7 @@ router.get('/installations', phase4RateLimit, agentRuntimeAuth, async (req: any,
podType: pod.type || 'agent-admin',
instanceId,
status: 'active',
...podVisibility(pod),
type: 'dm',
};
});
Expand Down
Loading
Loading