Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ jobs:
/usr/bin/unshare --user --map-root-user --fork /usr/bin/true

- name: Run the repository gate
env:
# Typed ESLint loads the complete repository project graph and can
# exceed V8's default heap on the 7 GiB macOS runner.
NODE_OPTIONS: "--max-old-space-size=4096"
run: bun run check

- name: Restore Ubuntu user-namespace restriction
Expand Down
21 changes: 16 additions & 5 deletions CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Cloud sync is optional. Local provider profiles, Codex credentials, Claude Code

- User messages and final assistant display text.
- Session names, notes, queued messages, and steering input.
- Codex account labels and observed provider email and plan metadata when cloud sync is enabled. Claude Code account identity and usage are not projected. HRA validates one bounded Claude Code authentication-status response transiently, reduces it to signedIn, and never retains, returns, projects, or uploads the identity or usage fields; it never opens or parses a Claude credential file. Devin account identity and allowance are not projected. HRA reports only local signed-in readiness and records provider-supplied session context and cost facts in the neutral session stream.
- Codex account labels and observed provider email and plan metadata when cloud sync is enabled. Claude Code account identity and usage are not projected. For managed profiles, HRA validates one bounded Claude Code authentication-status response transiently, reduces it to signedIn, and never retains, returns, projects, or uploads the identity or usage fields. Personal-home Claude adoption transiently reads bounded account, email, and organization identity metadata and retains only a one-way local authority key. Raw Claude identity fields and that private authority key are never publicly returned, projected, or uploaded; HRA never opens or parses a Claude credential file. Devin account identity and allowance are not projected. HRA reports only local signed-in readiness and records provider-supplied session context and cost facts in the neutral session stream.
- Codex and Claude Code personal-session adoption status: whether discovery is enabled and bounded pending, adopted, and fenced counts. Candidate identities and records are never included. Devin has no personal-home adoption surface.
- Turn timing, observed model and tier, and provider usage summaries.
- Bounded observed file and Git metadata, without unbounded filesystem paths.
- Observation-only interaction IDs, kinds, states, revisions, blocking status, and bounded safe summaries.
Expand All @@ -19,6 +20,7 @@ Cloud sync is optional. Local provider profiles, Codex credentials, Claude Code

- Codex, Claude Code, or Devin credentials; provider profile or configuration files; plugin credentials; OAuth access or refresh tokens; authorization codes; PKCE verifiers; provider cookies; or the private device code.
- Raw Codex app-server, Claude Code stream, or Devin ACP requests or responses.
- Personal-home adoption candidate identities or records, personal-runtime bindings, process identities, schedule-source metadata, provider-home provenance, provider-account authority hashes, or the automation id, firing time, and instructions from an exact Codex Desktop heartbeat envelope. Such an envelope is replaced with generic protected text before session content is projected.
- Raw reasoning, hidden chain of thought, or approval secrets.
- Provider-internal login and request IDs, permission values, MCP field contracts, protected answers, or response digests.
- Environment variables, arbitrary command output, or unbounded filesystem paths.
Expand Down
27 changes: 19 additions & 8 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions app/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
- Never persist plaintext projection text, an authentication token, or an unwrapped account key. Tokens live in the in-memory storage adapter and the account key lives in the custody context only.
- Keep local storage to the one key `app/src/data/card-order.ts` owns, holding a bounded list of opaque session public ids for the reader's own grid arrangement. `app/src/auth/no-persistent-storage.test.ts` allowlists that module by name; a second entry needs the same argument, and nothing else in the app may name `localStorage`, `sessionStorage`, or `document.cookie`.
- Show a schedule; never offer to change one. The scheduled-task badge and the settings list read the projected device registries and expose no create, edit, or delete anywhere.
- Show personal-session adoption only as per-machine provider aggregates with exact local CLI hints. Never add an adopted-session badge or let the browser grant access to a personal provider home.
- Persist only non-extractable `CryptoKey` objects, and only in IndexedDB. A private key must never be exportable.
- Drop the account key on idle, on `Ctrl+L`, and on the first authority error from Convex.
- A browser device is never the first device on an account and never approves another device.
Expand Down
49 changes: 49 additions & 0 deletions app/src/data/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,55 @@ describe("notification policy registry compatibility", () => {
});
});

test("drops legacy Codex Desktop automation metadata before it reaches the app", async () => {
const key = randomKeyBytes();
const authority = { devicePublicId, keyVersion: 1, userPublicId } as const;
const hraTask = {
cadence: "every 60 minutes",
id: "stask_public_hra_task",
kind: "hra_conversation",
label: "Public HRA task",
nextRunAt: 1_760_000_060_000,
sessionPublicId: "sess_public_hra_session",
} as const;
const privateAutomation = {
cadence: "FREQ=WEEKLY;BYDAY=MO",
id: "desktop-private-automation-id",
kind: "codex_automation",
label: "Desktop private automation label",
nextRunAt: null,
sessionPublicId: "sess_private_target_correlation",
} as const;
// Encrypt the legacy wire shape directly so this exercises an old daemon's
// existing row rather than the current writer, which already strips it.
const envelope = await encryptedJson({
...registryPayload(),
scheduledTasks: [hraTask, privateAutomation],
}, key, registryAad(authority));
const projection = await decryptRegistryProjection({
key,
row: parseRow({
devicePublicId,
envelope,
keyVersion: 1,
revision: 1,
updatedAt: 1,
}),
userPublicId,
});

expect(projection.registry.scheduledTasks).toEqual([hraTask]);
const appProjection = JSON.stringify(projection);
for (const privateValue of [
privateAutomation.id,
privateAutomation.label,
privateAutomation.cadence,
privateAutomation.sessionPublicId,
]) {
expect(appProjection).not.toContain(privateValue);
}
});

test("shows email consent only when the composite revision is current", async () => {
const key = randomKeyBytes();
const authority = { devicePublicId, keyVersion: 1, userPublicId } as const;
Expand Down
2 changes: 2 additions & 0 deletions app/src/hra/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export {
type DeviceRegistryPayload,
type DeviceRegistryProject,
type DeviceRegistryScheduledTask,
type DeviceRegistrySessionAdoption,
type DeviceRegistrySessionAdoptionStatus,
type RemoteCommandPayload,
type SessionMetadataPayload,
} from "../../../src/cloud/payloads";
Expand Down
11 changes: 5 additions & 6 deletions app/src/model/scheduled-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ function task(overrides: Partial<ScheduledTaskView> = {}): ScheduledTaskView {
return {
cadence: "every day at 09:00",
id: "task-1",
kind: "codex_automation",
kindLabel: "Codex",
kind: "hra_conversation",
kindLabel: "HRA",
label: "Morning sweep",
machineLabel: "workshop",
nextRunAt: now + hour,
Expand Down Expand Up @@ -50,6 +50,7 @@ function machine(
proseAutorespondConfigured: false,
revision: 1,
scheduledTasks,
sessionAdoption: null,
showThinkingDefault: false,
updatedAt: now,
};
Expand All @@ -62,10 +63,8 @@ describe("formatting", () => {
expect(scheduledTaskNextRun(null, now)).toBe("not scheduled");
});

test("the line names the provider, the cadence, and the next run", () => {
test("the line names HRA, the cadence, and the next run", () => {
expect(scheduledTaskLine(task(), now))
.toBe("Codex · every day at 09:00 · next run in 1 hour");
expect(scheduledTaskLine(task({ kind: "hra_conversation", kindLabel: "HRA" }), now))
.toBe("HRA · every day at 09:00 · next run in 1 hour");
});

Expand Down Expand Up @@ -126,7 +125,7 @@ describe("sessionScheduledTasks", () => {
"session-a",
now,
);
expect(view.rows[0]?.line).toBe("Codex · hourly · next run in 2 hours");
expect(view.rows[0]?.line).toBe("HRA · hourly · next run in 2 hours");
});

test("no machine, no match, and no session all render nothing", () => {
Expand Down
38 changes: 33 additions & 5 deletions app/src/model/settings-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
commandTargetForMachine,
isMachineOnline,
machineLabelsByDevice,
personalSessionAdoptionCommand,
registryHeartbeatToleranceMs,
scheduledTaskKindLabel,
shortSessionId,
Expand Down Expand Up @@ -44,7 +45,7 @@ function registry(overrides: Partial<DeviceRegistryPayload> = {}): DeviceRegistr
{
cadence: "every day at 09:00",
id: "task_one",
kind: "codex_automation",
kind: "hra_conversation",
label: "morning sweep",
nextRunAt: now + 3 * minute,
sessionPublicId: "sess_one",
Expand Down Expand Up @@ -138,6 +139,7 @@ describe("toMachineView", () => {
expect(view.defaultPreset).toBe("ultra");
expect(view.showThinkingDefault).toBe(false);
expect(view.proseAutorespondConfigured).toBe(true);
expect(view.sessionAdoption).toBeNull();
expect(view.devicePublicId).toBe("dev_one");
expect(view.deviceStatus).toBe("active");
expect(view.notificationHours).toEqual(notificationHours);
Expand All @@ -155,6 +157,33 @@ describe("toMachineView", () => {
expect(view.projects.map((project) => project.label)).toEqual(["hra"]);
});

test("renders personal-home consent as a local command in both directions", () => {
expect(personalSessionAdoptionCommand("codex", false))
.toBe("hra session adoption enable <account> --provider codex");
expect(personalSessionAdoptionCommand("claude", true))
.toBe("hra session adoption disable --provider claude");
});

test("carries exact provider aggregates and never guesses an older daemon's opt-in", () => {
const view = toMachineView({
device: { online: true, status: "active" },
devicePublicId: "dev_one",
now,
payload: registry({
sessionAdoption: {
claude: { adopted: 1, enabled: false, fenced: 2, pending: 3 },
codex: { adopted: 4, enabled: true, fenced: 5, pending: 6 },
},
}),
revision: 7,
updatedAt: now - minute,
});
expect(view.sessionAdoption).toEqual({
claude: { adopted: 1, enabled: false, fenced: 2, pending: 3 },
codex: { adopted: 4, enabled: true, fenced: 5, pending: 6 },
});
});

test("defaults an older registry to no displayable email consent", () => {
const view = toMachineView({
device: { online: true, status: "active" },
Expand All @@ -179,14 +208,13 @@ describe("toMachineView", () => {
updatedAt: now,
});
expect(view.scheduledTasks.map((task) => [task.label, task.kindLabel])).toEqual([
["morning sweep", "Codex"],
["morning sweep", "HRA"],
["weekly review", "HRA"],
]);
for (const task of view.scheduledTasks) expect(task.machineLabel).toBe("studio");
});

test("names both scheduled task kinds", () => {
expect(scheduledTaskKindLabel("codex_automation")).toBe("Codex");
test("names the public HRA conversation task kind", () => {
expect(scheduledTaskKindLabel("hra_conversation")).toBe("HRA");
});
});
Expand Down Expand Up @@ -263,7 +291,7 @@ describe("machine and task ordering", () => {
scheduledTasks: [{
cadence: "hourly",
id: "task_three",
kind: "codex_automation",
kind: "hra_conversation",
label: "hourly sweep",
nextRunAt: now + minute,
sessionPublicId: null,
Expand Down
27 changes: 25 additions & 2 deletions app/src/model/settings-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
DeviceRegistryPayload,
DeviceRegistryProject,
DeviceRegistryScheduledTask,
DeviceRegistrySessionAdoption,
NotificationHoursPolicy,
} from "../hra/cloud";
import type { ApprovalMode, PresetChoice } from "./settings-commands";
Expand All @@ -26,6 +27,21 @@ export const registryHeartbeatIntervalMs = 60_000;
/** Three missed registry heartbeats before a machine reads as offline. */
export const registryHeartbeatToleranceMs = 3 * registryHeartbeatIntervalMs;

export type SessionAdoptionProvider = keyof DeviceRegistrySessionAdoption;

/**
* Personal-home access is a machine-local consent boundary. Settings shows
* the exact local command instead of manufacturing a browser mutation.
*/
export function personalSessionAdoptionCommand(
provider: SessionAdoptionProvider,
enabled: boolean,
): string {
return enabled
? `hra session adoption disable --provider ${provider}`
: `hra session adoption enable <account> --provider ${provider}`;
}

export type MachineDeviceState = Readonly<{
online: boolean;
status: "pending" | "active" | "revoked";
Expand All @@ -52,12 +68,16 @@ export function isMachineOnline(input: MachineOnlineInput): boolean {
return now - heartbeatAt <= registryHeartbeatToleranceMs;
}

export type ScheduledTaskKindLabel = "Codex" | "HRA";
export type ScheduledTaskKindLabel = "HRA";

const scheduledTaskKindLabels: Readonly<
Record<DeviceRegistryScheduledTask["kind"], ScheduledTaskKindLabel>
> = { hra_conversation: "HRA" };

export function scheduledTaskKindLabel(
kind: DeviceRegistryScheduledTask["kind"],
): ScheduledTaskKindLabel {
return kind === "codex_automation" ? "Codex" : "HRA";
return scheduledTaskKindLabels[kind];
}

export type ScheduledTaskView = Readonly<{
Expand Down Expand Up @@ -97,6 +117,8 @@ export type MachineView = Readonly<{
proseAutorespondConfigured: boolean;
revision: number;
scheduledTasks: readonly ScheduledTaskView[];
/** Null means the daemon predates this optional registry projection. */
sessionAdoption: DeviceRegistrySessionAdoption | null;
showThinkingDefault: boolean;
updatedAt: number;
}>;
Expand Down Expand Up @@ -147,6 +169,7 @@ export function toMachineView(input: MachineViewInput): MachineView {
nextRunAt: task.nextRunAt,
sessionPublicId: task.sessionPublicId,
})),
sessionAdoption: payload.sessionAdoption ?? null,
showThinkingDefault: payload.showThinkingDefault,
notificationHours: input.notificationHours ?? null,
notificationHoursStatus: input.notificationHoursStatus
Expand Down
1 change: 1 addition & 0 deletions app/src/screens/notification-hours-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ const machine: MachineView = {
proseAutorespondConfigured: false,
revision: 8,
scheduledTasks: [],
sessionAdoption: null,
showThinkingDefault: false,
updatedAt: 1_760_000_000_000,
};
Expand Down
8 changes: 8 additions & 0 deletions app/src/screens/settings-screen.devin.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ await mock.module("../data/registry", () => ({
proseAutorespondConfigured: false,
revision: 1,
scheduledTasks: [],
sessionAdoption: {
claude: { adopted: 0, enabled: false, fenced: 0, pending: 0 },
codex: { adopted: 0, enabled: false, fenced: 0, pending: 0 },
},
showThinkingDefault: false,
updatedAt: 1_760_000_000_000,
} satisfies MachineView],
Expand All @@ -100,5 +104,9 @@ describe("Devin account settings", () => {
expect(markup).toContain("--manual-token-flow");
expect(markup).not.toContain("Link here");
expect(markup).not.toContain("Check status");
expect(markup).toContain("Codex personal sessions");
expect(markup).toContain("Claude Code personal sessions");
expect(markup).not.toContain("Devin personal sessions");
expect(markup).not.toContain("session adoption enable &lt;account&gt; --provider devin");
});
});
35 changes: 35 additions & 0 deletions app/src/screens/settings-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
attentionEmailPresentation,
commandTargetForMachine,
machineLabelsByDevice,
personalSessionAdoptionCommand,
shortSessionId,
type AccountRowView,
type ArchivedSessionView,
Expand Down Expand Up @@ -173,6 +174,11 @@ function openSession(sessionPublicId: string): void {
location.hash = `#/session/${sessionPublicId}`;
}

const personalSessionProviders = Object.freeze([
{ label: "Codex", provider: "codex" },
{ label: "Claude Code", provider: "claude" },
] as const);

/**
* The gateway key entry.
*
Expand Down Expand Up @@ -465,6 +471,35 @@ function MachineCard({
/>
)}

{machine.sessionAdoption === null ? (
<SettingsRow
control={<Badge tone="neutral">unavailable</Badge>}
description="Update HRA on this machine to publish its local adoption status."
title="Personal sessions"
/>
) : personalSessionProviders.map(({ label, provider }) => {
const adoption = machine.sessionAdoption?.[provider];
if (adoption === undefined) return null;
const command = personalSessionAdoptionCommand(provider, adoption.enabled);
return (
<SettingsRow
control={(
<Badge tone={adoption.enabled ? "accent" : "neutral"}>
{adoption.enabled ? "enabled" : "off"}
</Badge>
)}
description={`${adoption.pending} pending, ${adoption.adopted} adopted, and ${adoption.fenced} fenced. ${adoption.enabled ? "New discovery is enabled." : "Disabling discovery does not change sessions already under HRA control."}`}
key={provider}
title={`${label} personal sessions`}
>
<p className="text-xs text-ink-muted">
Personal-home access can be changed only from this machine.
</p>
<CommandHint>{command}</CommandHint>
</SettingsRow>
);
})}

<SettingsRow
control={(
<Badge tone={machine.proseAutorespondConfigured ? "accent" : "neutral"}>
Expand Down
Loading