feat: credit linked session participants as Git co-authors (#125827)

* feat: credit linked session participants as co-authors

Authenticated profiles can link GitHub and receive automatic co-author credit in shared coding sessions.

* style: format rebased co-author registries

* fix: mark profile schema DDL boundary
This commit is contained in:
Peter Steinberger
2026-08-18 07:15:01 -07:00
committed by GitHub
parent 902eb1f282
commit 4048ae26e0
49 changed files with 1802 additions and 240 deletions
@@ -592,6 +592,8 @@ enum class GatewayMethod(
ToolsGithubStatus("tools.github.status"),
ToolsGithubConfigure("tools.github.configure"),
DiagnosticsLanes("diagnostics.lanes"),
UsersSetGitHubIdentity("users.setGitHubIdentity"),
UsersClearGitHubIdentity("users.clearGitHubIdentity"),
}
enum class GatewayEvent(
+1 -1
View File
@@ -3868,7 +3868,7 @@ src/state/openclaw-state-lease.ts 1
src/state/openclaw-state-ownership.ts 1
src/state/openclaw-state-snapshot-sanitizer.ts 1
src/state/user-profiles-tailscale-avatar.ts 2
src/state/user-profiles.ts 3
src/state/user-profiles.ts 1
src/status/link-channel.ts 1
src/status/status-message.ts 3
src/status/status-text.ts 3
+2
View File
@@ -78,6 +78,8 @@ Start a session as a draft to keep work in progress out of teammates' sidebars u
Turn sender attribution is best-effort. Steering can merge input into an active turn, so the transcript cannot always represent each person's contribution as a separate turn. Participant history records that an actor prompted the session, not which words were theirs.
Authenticated people can link a GitHub account under **Settings → Profile → Identity**. Linking is an explicit opt-in to public `Co-authored-by` credit on commits an agent creates from sessions they have prompted. Attribution uses the durable profile participant records described above, not display names or the four-person facepile projection. See [User model](/concepts/user-model#gateway-profile-and-github-credit) for privacy, eligibility, bounds, and unlink behavior.
## Related
- [The main session](/concepts/main-session)
+14 -1
View File
@@ -1,16 +1,29 @@
---
summary: "Store durable user preferences and profile facts as directive-based USER.md entries"
summary: "Manage durable user preferences and your Gateway profile identity"
title: "User model"
read_when:
- You want stable preferences to guide future sessions
- You need to update a preference without leaving contradictory history
- You are deciding whether something belongs in USER.md or MEMORY.md
- You want to link GitHub credit to your Gateway profile
---
`USER.md` is the optional user-model artifact in an agent workspace. It stores stable preferences, communication style, relationships, and active-project context as directives that can guide future sessions.
OpenClaw loads `USER.md` beside `MEMORY.md` at session start. It has a separate small bootstrap budget, and edits are picked up on later turns in a long-lived session. If the file is absent, startup continues without it.
## Gateway profile and GitHub credit
Your authenticated Gateway profile is separate from `USER.md`. Open **Settings → Profile → Identity** to set the display name and avatar shown to other people on the Gateway. A custom OpenClaw avatar remains authoritative even when you link GitHub.
Enter a GitHub username in the **GitHub** row to opt into public commit attribution. The Gateway resolves the public account through GitHub, stores its stable numeric account id and current login, and derives a GitHub noreply address. OpenClaw never requests or stores a private GitHub email for this feature.
When your authenticated profile has prompted a session before an agent run, commits created from that run receive your exact `Co-authored-by` trailer. All linked profile-backed human participants are eligible; channel-only identities, agents, bots, and the configured primary Git author are excluded. The participant set is bounded to 32 and recorded best-effort. The run tells the model when an eligible profile is unlinked or the bound may be incomplete; it never guesses an identity from transcript names.
OpenClaw supplies exact trailers in the model context for that turn and instructs coding agents to retain them through amendments, rebases, and squash commits so credit reaches the final commit merged to the default branch. The trailers are not exported through the process or shell environment. Git commands remain ordinary shell execution: OpenClaw does not replace `git` or install repository hooks, so the instruction and post-commit verification are the enforcement boundary.
Changing the linked username resolves and stores the new public account. **Disconnect** stops attribution for future runs; it does not rewrite commits that already contain the public trailer.
## Write directives, not observations
Each entry has a metadata line followed by one imperative directive:
+4 -2
View File
@@ -104,9 +104,11 @@ For connections with a durable user profile, the Gateway stores each agent's lat
On the first identified connection, the Control UI uploads existing browser-local new-session preferences only when the Gateway has no such preferences yet. Later changes write to the Gateway first and then update the browser mirror. Connections without a durable identity continue using browser-local preferences and the loaded session roster for recents.
## Personal identity (browser-local)
## Personal identity
The Control UI supports a per-browser personal identity (display name and avatar) attached to outgoing messages, for attribution in shared sessions. It lives in browser storage, scoped to the current browser profile, and is not synced to other devices or persisted server-side beyond the normal transcript authorship metadata on messages you send. Clearing site data or switching browsers resets it to empty.
Authenticated people have a durable Gateway profile with a display name, avatar, linked emails, and optional GitHub identity. Open **Settings → Profile → Identity** to update it. The profile follows the authenticated person across browsers and supplies attribution in shared sessions; clearing browser site data does not delete it.
Linking GitHub opts the profile into public commit co-author credit for agent sessions that person prompts. The row shows GitHub's public account avatar and link without replacing a custom OpenClaw avatar. See [User model](/concepts/user-model#gateway-profile-and-github-credit) for the noreply privacy and eligibility rules.
The assistant avatar override follows the same browser-local pattern: uploaded overrides overlay the gateway-resolved identity locally and never round-trip through `config.patch`. The shared `ui.assistant.avatar` config field is still available for non-UI clients that write the field directly.
@@ -20,6 +20,11 @@ export const UserProfileAvatarMimeSchema = Type.Union([
Type.Literal("image/jpeg"),
Type.Literal("image/webp"),
]);
export const UserProfileGitHubIdentitySchema = closedObject({
login: Type.String({ minLength: 1, maxLength: 39 }),
profileUrl: NonEmptyString,
avatarUrl: NonEmptyString,
});
export const UserProfileSchema = closedObject({
id: UserProfileIdSchema,
@@ -29,6 +34,7 @@ export const UserProfileSchema = closedObject({
createdAt: Type.Integer({ minimum: 0 }),
updatedAt: Type.Integer({ minimum: 0 }),
emails: Type.Array(NonEmptyString),
githubIdentity: Type.Union([UserProfileGitHubIdentitySchema, Type.Null()]),
hasAvatar: Type.Boolean(),
});
@@ -60,6 +66,13 @@ export const UsersSetAvatarResultSchema = closedObject({
avatarRevision: NonEmptyString,
});
export const UsersSetGitHubIdentityParamsSchema = closedObject({
username: Type.String({ minLength: 1, maxLength: 39 }),
});
export const UsersSetGitHubIdentityResultSchema = closedObject({ profile: UserProfileSchema });
export const UsersClearGitHubIdentityParamsSchema = closedObject({});
export const UsersClearGitHubIdentityResultSchema = closedObject({ profile: UserProfileSchema });
export const UsersPrefsGetParamsSchema = closedObject({
keys: Type.Optional(
Type.Array(UserPreferenceKeySchema, {
@@ -79,6 +92,7 @@ export const UsersPrefsSetResultSchema = Type.Union([
]);
export type UserProfile = Static<typeof UserProfileSchema>;
export type UserProfileGitHubIdentity = Static<typeof UserProfileGitHubIdentitySchema>;
export type UsersListParams = Static<typeof UsersListParamsSchema>;
export type UsersListResult = Static<typeof UsersListResultSchema>;
export type UsersSelfParams = Static<typeof UsersSelfParamsSchema>;
@@ -89,6 +103,10 @@ export type UsersSetDisplayNameParams = Static<typeof UsersSetDisplayNameParamsS
export type UsersSetDisplayNameResult = Static<typeof UsersSetDisplayNameResultSchema>;
export type UsersSetAvatarParams = Static<typeof UsersSetAvatarParamsSchema>;
export type UsersSetAvatarResult = Static<typeof UsersSetAvatarResultSchema>;
export type UsersSetGitHubIdentityParams = Static<typeof UsersSetGitHubIdentityParamsSchema>;
export type UsersSetGitHubIdentityResult = Static<typeof UsersSetGitHubIdentityResultSchema>;
export type UsersClearGitHubIdentityParams = Static<typeof UsersClearGitHubIdentityParamsSchema>;
export type UsersClearGitHubIdentityResult = Static<typeof UsersClearGitHubIdentityResultSchema>;
export type UsersPrefsGetParams = Static<typeof UsersPrefsGetParamsSchema>;
export type UsersPrefsGetResult = Static<typeof UsersPrefsGetResultSchema>;
export type UsersPrefsSetParams = Static<typeof UsersPrefsSetParamsSchema>;
@@ -104,6 +104,14 @@ export const validateUsersSetDisplayNameParams = compile(S.UsersSetDisplayNamePa
export const validateUsersSetDisplayNameResult = compile(S.UsersSetDisplayNameResultSchema);
export const validateUsersSetAvatarParams = compile(S.UsersSetAvatarParamsSchema);
export const validateUsersSetAvatarResult = compile(S.UsersSetAvatarResultSchema);
export const validateUsersSetGitHubIdentityParams = compile(S.UsersSetGitHubIdentityParamsSchema);
export const validateUsersSetGitHubIdentityResult = compile(S.UsersSetGitHubIdentityResultSchema);
export const validateUsersClearGitHubIdentityParams = compile(
S.UsersClearGitHubIdentityParamsSchema,
);
export const validateUsersClearGitHubIdentityResult = compile(
S.UsersClearGitHubIdentityResultSchema,
);
export const validateAgentIdentityParams = compile(S.AgentIdentityParamsSchema);
export const validateAgentWaitParams = compile(S.AgentWaitParamsSchema);
export const validateWakeParams = compile(S.WakeParamsSchema);
+1
View File
@@ -1096,6 +1096,7 @@ async function createChatPickerScenario(
createdAt: baseTime,
updatedAt: baseTime,
emails: ["riley@example.com"],
githubIdentity: null,
hasAvatar: false,
};
const devicePairSetupCode = Buffer.from(
@@ -77,19 +77,6 @@ describe("exec GitHub identity", () => {
}
});
it("preserves ambient service tokens for unconfigured local gateway exec", () => {
setTestEnvValue("GH_TOKEN", "ambient-token");
setTestEnvValue("GITHUB_TOKEN", "ambient-fallback");
const prepared = prepareGitHubToolEnvironment({ config: {}, agentId: "main" });
for (const host of ["gateway", "node", "sandbox"] as const) {
const result = prepare(host, prepared, false);
expect(result.env.GH_TOKEN).toBe(host === "gateway" ? "ambient-token" : undefined);
expect(result.env.GITHUB_TOKEN).toBe(host === "gateway" ? "ambient-fallback" : undefined);
expect(result.requestedEnv).toBeUndefined();
}
});
it.each([
{ previewName: "GH_TOKEN", otherName: "GITHUB_TOKEN" },
{ previewName: "GITHUB_TOKEN", otherName: "GH_TOKEN" },
@@ -2898,6 +2898,60 @@ describe("CLI attempt execution", () => {
});
});
it("adds Git attribution only to provider-bound CLI and plugin prompts", async () => {
const attribution =
"Git commit attribution for this turn:\nCo-authored-by: octocat <583231+octocat@users.noreply.github.com>";
const sessionKey = "agent:main:direct:coauthor-runtime-prompts";
const sessionEntry = makeSessionEntry("coauthor-runtime-prompts");
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await writeSessionStoreSeed(sessionStore);
runCliAgentMock.mockResolvedValueOnce(makeCliResult("cli result"));
await runStoredAttempt({
providerOverride: "claude-cli",
modelOverride: "opus",
sessionEntry,
sessionKey,
body: "commit from CLI",
runId: "run-cli-coauthor-prompt",
opts: { gitCoauthorAttribution: attribution },
sessionStore,
});
const cliArg = firstRunCliAgentArg();
const attributionSuffix = `\n\n${attribution}`;
expect(cliArg.prompt).toEqual(expect.stringContaining("commit from CLI"));
expect(String(cliArg.prompt).endsWith(attributionSuffix)).toBe(true);
expect(cliArg.transcriptPrompt).toBe(String(cliArg.prompt).slice(0, -attributionSuffix.length));
const codexSessionKey = "agent:main:direct:coauthor-codex-prompt";
const codexSessionEntry = makeSessionEntry("coauthor-codex-prompt");
const codexSessionStore: Record<string, SessionEntry> = {
[codexSessionKey]: codexSessionEntry,
};
await writeSessionStoreSeed(codexSessionStore);
runEmbeddedAgentMock.mockResolvedValueOnce({
meta: { durationMs: 1 },
} satisfies EmbeddedAgentRunResult);
await runStoredAttempt({
agentHarnessRuntimeOverride: "codex",
body: "commit from Codex",
sessionEntry: codexSessionEntry,
sessionKey: codexSessionKey,
runId: "run-codex-coauthor-prompt",
opts: { gitCoauthorAttribution: attribution },
sessionStore: codexSessionStore,
});
const codexArg = firstEmbeddedAgentArg();
expectRecordFields(codexArg, {
agentHarnessId: "codex",
prompt: `commit from Codex\n\n${attribution}`,
transcriptPrompt: "commit from Codex",
});
});
it("keeps live stream output for visible subagent lane runs", async () => {
const embeddedArg = await runOpenClawEmbeddedAttemptForTest({
opts: { lane: "subagent" },
+16 -4
View File
@@ -77,6 +77,7 @@ import {
import { resolveConversationCapabilityProfile } from "../conversation-capability-profile.js";
import { resolveConversationToolPolicies } from "../conversation-tool-policy-pipeline.js";
import { runEmbeddedAgent, type EmbeddedAgentRunResult } from "../embedded-agent.js";
import { appendGitCoauthorContext } from "../git-coauthor-attribution.js";
import type { ContextEngineLogicalTurnLease } from "../harness/context-engine-logical-turn.js";
import type { ContextEngineTurnAttemptFacts } from "../harness/context-engine-turn-attempt.js";
import { runAgentHarnessBeforeMessageWriteHook } from "../harness/hook-helpers.js";
@@ -823,6 +824,10 @@ export function runAgentAttempt(params: {
params.opts.inputProvenance?.kind === "inter_session"
? cliEffectivePrompt
: injectTimestamp(cliEffectivePrompt, timestampOptsFromConfig(params.cfg));
const cliModelPrompt = appendGitCoauthorContext(cliPrompt, params.opts.gitCoauthorAttribution);
const cliPersistencePrompt = params.opts.gitCoauthorAttribution
? (cliTranscriptPrompt ?? cliPrompt)
: cliTranscriptPrompt;
const mutableCliSessionStore =
params.sessionKey && params.sessionStore && params.storePath
? {
@@ -920,8 +925,8 @@ export function runAgentAttempt(params: {
workspaceDir: params.workspaceDir,
cwd: params.cwd,
config: params.cfg,
prompt: cliPrompt,
transcriptPrompt: cliTranscriptPrompt,
prompt: cliModelPrompt,
transcriptPrompt: cliPersistencePrompt,
modelProvider: params.providerOverride,
modelHasVision: params.modelHasVision,
provider: cliExecutionProvider,
@@ -1114,6 +1119,13 @@ export function runAgentAttempt(params: {
});
}
const embeddedModelPrompt = appendGitCoauthorContext(
effectivePrompt,
params.opts.gitCoauthorAttribution,
);
const embeddedPersistencePrompt = params.opts.gitCoauthorAttribution
? (continuationTranscriptBody ?? effectivePrompt)
: continuationTranscriptBody;
const embeddedRunParams: Parameters<typeof runEmbeddedAgent>[0] = {
preparedRunAdmission: params.preparedRunAdmission,
sessionId: params.sessionId,
@@ -1151,8 +1163,8 @@ export function runAgentAttempt(params: {
modelSelectionLocked: !isRawModelRun && params.sessionEntry?.modelSelectionLocked === true,
agentHarnessRuntimeOverride: embeddedAgentHarnessOverride,
skillsSnapshot: params.skillsSnapshot,
prompt: effectivePrompt,
transcriptPrompt: continuationTranscriptBody,
prompt: embeddedModelPrompt,
transcriptPrompt: embeddedPersistencePrompt,
// CLI-origin retries cannot rely on transcript replay: orphan-user repair
// removes the persisted CLI turn before the embedded prompt is submitted.
images: shouldForwardImagesToEmbedded ? params.opts.images : undefined,
+2
View File
@@ -145,6 +145,8 @@ export type AgentCommandOpts = {
/** Called once when the selected runtime actually admits the prompt for execution. */
onExecutionStarted?: () => void;
extraSystemPrompt?: string;
/** Frozen profile-backed human Git attribution prepared by trusted ingress. */
gitCoauthorAttribution?: string;
/** Bootstrap workspace context injection mode for this run. */
bootstrapContextMode?: "full" | "lightweight";
/** Run kind hint for bootstrap context behavior. */
+123
View File
@@ -0,0 +1,123 @@
import { afterEach, describe, expect, it } from "vitest";
import {
MAX_SESSION_PARTICIPANTS,
recordSessionParticipant,
upsertSessionEntryCore,
} from "../config/sessions/session-accessor.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { ensureProfileForEmail, setGitHubIdentity } from "../state/user-profiles.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import {
appendGitCoauthorContext,
prepareGitCoauthorAttribution,
} from "./git-coauthor-attribution.js";
afterEach(() => {
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
});
describe("Git co-author attribution", () => {
it("derives exact bounded trailers only from canonical profile-backed humans", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async (state) => {
const sessionKey = "agent:main:coauthors";
const profile = (email: string, accountId?: number, login?: string) => {
const value = ensureProfileForEmail(email, { env: state.env });
return accountId && login
? setGitHubIdentity(value.id, { accountId, login }, { env: state.env })
: value;
};
const ada = profile("ada@example.test", 20, "ada");
const grace = profile("grace@example.test", 10, "grace");
const primary = profile("primary@example.test", 30, "primary");
const current = profile("current@example.test", 15, "current");
const unlinked = profile("unlinked@example.test");
const scope = { agentId: "main", env: state.env, sessionKey };
await upsertSessionEntryCore(scope, { sessionId: "coauthors", updatedAt: 1 });
for (const participant of [ada, grace, primary, unlinked]) {
recordSessionParticipant(scope, {
actor: { type: "human", id: participant.id },
source: "profile",
sessionAgentId: "main",
});
}
recordSessionParticipant(scope, {
actor: { type: "human", id: ada.id },
source: "profile",
sessionAgentId: "main",
});
recordSessionParticipant(scope, {
actor: { type: "human", id: current.id },
source: "channel",
sessionAgentId: "main",
});
recordSessionParticipant(scope, {
actor: { type: "agent", id: "helper" },
source: "agent",
sessionAgentId: "main",
});
const attribution = prepareGitCoauthorAttribution({
agentId: "main",
config: {
tools: {
github: {
profileId: "ghp_11111111111111111111111111111111",
gitAuthor: {
email: "30+primary@users.noreply.github.com",
},
},
},
},
currentProfileId: current.id,
env: state.env,
sessionKey,
storePath: state.statePath("agents", "main", "agent", "openclaw-agent.sqlite"),
});
const modelPrompt = appendGitCoauthorContext("commit this", attribution);
expect(modelPrompt).toContain(
[
"Co-authored-by: grace <10+grace@users.noreply.github.com>",
"Co-authored-by: current <15+current@users.noreply.github.com>",
"Co-authored-by: ada <20+ada@users.noreply.github.com>",
].join("\n"),
);
expect(modelPrompt).toContain(
"1 eligible profile participant(s) have no linked GitHub account and were omitted",
);
expect(modelPrompt).toContain(
"1 linked profile participant(s) match the configured primary Git author",
);
});
});
it("makes the participant bound visible without guessing beyond it", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async (state) => {
const sessionKey = "agent:main:coauthor-cap";
const scope = { agentId: "main", env: state.env, sessionKey };
await upsertSessionEntryCore(scope, { sessionId: "coauthor-cap", updatedAt: 1 });
for (let index = 0; index < MAX_SESSION_PARTICIPANTS; index += 1) {
recordSessionParticipant(scope, {
actor: { type: "human", id: `missing-${index}` },
source: "profile",
sessionAgentId: "main",
});
}
const current = ensureProfileForEmail("current@example.test", { env: state.env });
setGitHubIdentity(current.id, { accountId: 99, login: "current" }, { env: state.env });
const attribution = prepareGitCoauthorAttribution({
agentId: "main",
config: {},
currentProfileId: current.id,
env: state.env,
sessionKey,
storePath: state.statePath("agents", "main", "agent", "openclaw-agent.sqlite"),
});
expect(attribution).toContain("bounded participant history may be incomplete");
expect(attribution).not.toContain("Co-authored-by: current");
});
});
});
+86
View File
@@ -0,0 +1,86 @@
import { listSessionParticipantsReadOnly } from "../config/sessions/session-accessor.js";
import { resolveBoundedProfileParticipantSnapshot } from "../config/sessions/session-accessor.sqlite-participant-projection.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveUserProfileGitHubAttribution } from "../state/user-profile-github-identity.js";
import { resolveConfiguredGitHubToolIdentity } from "./github-tool-identity.js";
export function appendGitCoauthorContext(prompt: string, attribution: string | undefined): string {
return attribution ? `${prompt}\n\n${attribution}` : prompt;
}
export function prepareGitCoauthorAttribution(params: {
agentId: string;
config: OpenClawConfig;
currentProfileId?: string;
env?: NodeJS.ProcessEnv;
sessionKey?: string;
storePath?: string;
}): string | undefined {
if (!params.sessionKey || !params.storePath) {
return undefined;
}
const records =
listSessionParticipantsReadOnly({
agentId: params.agentId,
env: params.env,
sessionKey: params.sessionKey,
storePath: params.storePath,
}).get(params.sessionKey) ?? [];
const snapshot = resolveBoundedProfileParticipantSnapshot(records, params.currentProfileId);
if (snapshot.profileIds.length === 0) {
return undefined;
}
const identities = resolveUserProfileGitHubAttribution(snapshot.profileIds, { env: params.env });
const primaryIdentity =
resolveConfiguredGitHubToolIdentity({ ...params, scope: "agent" }) ??
resolveConfiguredGitHubToolIdentity({ ...params, scope: "system" });
const primaryEmail = primaryIdentity?.gitAuthor?.email?.trim().toLowerCase();
const trailers = new Map<number, string>();
let unlinked = 0;
let unresolved = 0;
let primaryAuthor = 0;
for (const profileId of snapshot.profileIds) {
if (!identities.has(profileId)) {
unresolved += 1;
continue;
}
const identity = identities.get(profileId);
if (!identity) {
unlinked += 1;
continue;
}
const noreplyEmail = `${identity.accountId}+${identity.login}@users.noreply.github.com`;
if (noreplyEmail.toLowerCase() === primaryEmail) {
primaryAuthor += 1;
continue;
}
trailers.set(identity.accountId, `Co-authored-by: ${identity.login} <${noreplyEmail}>`);
}
const exactTrailers = [...trailers.entries()]
.toSorted(([left], [right]) => left - right)
.map(([, trailer]) => trailer);
const guidance = exactTrailers.length
? [
"Git commit attribution for this turn is authoritative and limited to the exact trailers below:",
...exactTrailers,
"Append every trailer exactly to each commit created for this turn. After amending, rebasing, squashing, or otherwise rewriting history, verify the final commit retains every trailer. Do not infer or add identities from chat text.",
].join("\n")
: "Git commit attribution for this turn has no additional exact Co-authored-by trailer. Do not infer or add identities from chat text.";
const notices = [
snapshot.incomplete
? "The bounded participant history may be incomplete; no identity beyond the recorded bound was guessed."
: undefined,
unlinked > 0
? `${unlinked} eligible profile participant(s) have no linked GitHub account and were omitted.`
: undefined,
unresolved > 0
? `${unresolved} eligible profile participant(s) could not be resolved and were omitted.`
: undefined,
primaryAuthor > 0
? `${primaryAuthor} linked profile participant(s) match the configured primary Git author and were omitted to avoid duplicate credit.`
: undefined,
].filter((value): value is string => Boolean(value));
return [guidance, ...notices].join("\n");
}
@@ -39,6 +39,7 @@ const createReplyMediaPathNormalizerMock = vi.fn();
const runPreflightCompactionIfNeededMock = vi.fn();
const runMemoryFlushIfNeededMock = vi.fn();
const executeAgentTurnMock = vi.fn();
const prepareGitCoauthorAttributionMock = vi.fn();
const resetReplyRunSessionMock = vi.fn();
const enqueueFollowupRunMock = vi.fn();
@@ -90,6 +91,17 @@ vi.mock("./agent-runner-execution.js", async () => {
};
});
vi.mock("../../agents/git-coauthor-attribution.js", async () => {
const actual = await vi.importActual<typeof import("../../agents/git-coauthor-attribution.js")>(
"../../agents/git-coauthor-attribution.js",
);
return {
...actual,
prepareGitCoauthorAttribution: (...args: unknown[]) =>
prepareGitCoauthorAttributionMock(...args),
};
});
vi.mock("./agent-runner-session-reset.js", async () => {
const actual = await vi.importActual<typeof import("./agent-runner-session-reset.js")>(
"./agent-runner-session-reset.js",
@@ -259,6 +271,7 @@ describe("runReplyAgent runtime config", () => {
runPreflightCompactionIfNeededMock.mockReset();
runMemoryFlushIfNeededMock.mockReset();
executeAgentTurnMock.mockReset();
prepareGitCoauthorAttributionMock.mockReset();
resetReplyRunSessionMock.mockReset();
enqueueFollowupRunMock.mockReset();
@@ -272,6 +285,7 @@ describe("runReplyAgent runtime config", () => {
runId: "runtime-config-test",
outcome: { kind: "rejected", payload: { text: "main reply" } },
});
prepareGitCoauthorAttributionMock.mockReturnValue(undefined);
resetReplyRunSessionMock.mockResolvedValue(false);
});
@@ -344,6 +358,69 @@ describe("runReplyAgent runtime config", () => {
expect(memoryCall.runtimePolicySessionKey).toBe(runtimePolicySessionKey);
});
it("forwards co-author context only for trusted profile-backed session creation", async () => {
const attribution =
"Git commit attribution for this turn:\nCo-authored-by: octocat <583231+octocat@users.noreply.github.com>";
prepareGitCoauthorAttributionMock.mockImplementation((params: { currentProfileId?: string }) =>
params.currentProfileId === "profile-ada" ? attribution : undefined,
);
runPreflightCompactionIfNeededMock.mockResolvedValue(undefined);
await withTestDir({ prefix: "openclaw-coauthor-owner-" }, async (tempDir) => {
const storePath = join(tempDir, "sessions.json");
const runCase = async (
suffix: string,
creation: NonNullable<TemplateContext["SessionCreation"]>,
) => {
const { replyParams } = createDirectRuntimeReplyParams({
shouldFollowup: false,
isActive: false,
});
const sessionKey = `agent:main:chat:${suffix}`;
const sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1 };
Object.assign(replyParams, {
sessionKey,
storePath,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionCtx: { ...createTelegramSessionCtx(), SessionCreation: creation },
});
await replaceSessionEntry({ storePath, sessionKey }, sessionEntry);
await runReplyAgent(replyParams);
return executeAgentTurnMock.mock.calls.at(-1)?.[0];
};
const profileCall = await runCase("profile", {
via: "operator",
actor: { type: "human", id: "profile-ada" },
});
expect(prepareGitCoauthorAttributionMock).toHaveBeenLastCalledWith({
agentId: "main",
config: freshCfg,
currentProfileId: "profile-ada",
sessionKey: "agent:main:chat:profile",
storePath,
});
expect(profileCall).toMatchObject({
opts: { gitCoauthorAttribution: attribution },
});
const channelCall = await runCase("channel", {
via: "channel",
actor: { type: "human", id: "channel-user" },
});
expect(prepareGitCoauthorAttributionMock).toHaveBeenLastCalledWith({
agentId: "main",
config: freshCfg,
currentProfileId: undefined,
sessionKey: "agent:main:chat:channel",
storePath,
});
expect(channelCall).not.toHaveProperty("opts.gitCoauthorAttribution");
});
});
it("continues the main reply after a recorded memory-flush failure", async () => {
const { replyParams } = createDirectRuntimeReplyParams({
shouldFollowup: false,
+17 -4
View File
@@ -1,8 +1,10 @@
import crypto from "node:crypto";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { isLikelyContextOverflowError } from "../../agents/failover/classify.js";
import { prepareGitCoauthorAttribution } from "../../agents/git-coauthor-attribution.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { resolveProfileParticipantIdFromSessionCreation } from "../../config/sessions/session-entry-provenance.js";
import { logVerbose } from "../../globals.js";
import { withBeforeAgentReplyObserver } from "../../plugins/before-agent-reply.js";
import { setReplyPayloadMetadata } from "../reply-payload.js";
@@ -356,8 +358,18 @@ export async function executePreparedReplyAgentRun(
return { ...hookResult, reply: hookReply };
},
},
() =>
traceAgentPhase("reply.run_agent_turn", () =>
() => {
const gitCoauthorAttribution = prepareGitCoauthorAttribution({
agentId: followupRun.run.agentId,
config: cfg,
currentProfileId: resolveProfileParticipantIdFromSessionCreation(
sessionCtx.SessionCreation,
),
sessionKey,
storePath,
});
const agentTurnOpts = gitCoauthorAttribution ? { ...opts, gitCoauthorAttribution } : opts;
return traceAgentPhase("reply.run_agent_turn", () =>
executeAgentTurn({
commandBody,
transcriptCommandBody,
@@ -365,7 +377,7 @@ export async function executePreparedReplyAgentRun(
sessionCtx,
replyThreading: replyThreadingOverride ?? sessionCtx.ReplyThreading,
replyOperation,
opts,
opts: agentTurnOpts,
typingSignals,
blockReplyPipeline,
blockStreamingEnabled,
@@ -387,7 +399,8 @@ export async function executePreparedReplyAgentRun(
replyMediaContext,
isRestartRecoveryArmed,
}),
),
);
},
);
const operationSuperseded = isReplyOperationSuperseded(replyOperation);
recordReplyOperationAgentTurn(
+36 -1
View File
@@ -1512,7 +1512,7 @@ describe("initSessionState RawBody", () => {
});
});
it("records channel senders but skips unknown and own-agent prompt identities", async () => {
it("keeps channel and agent participant sources distinct", async () => {
const root = await makeCaseDir("openclaw-session-participant-admission-");
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath } } as OpenClawConfig;
@@ -1534,6 +1534,16 @@ describe("initSessionState RawBody", () => {
},
cfg,
});
await initSessionState({
ctx: {
RawBody: "channel-created prompt",
ChatType: "direct",
SessionKey: "agent:main:channel-created-participant",
SenderId: "channel-created-sender",
SessionCreation: { via: "channel", actor: { type: "human", id: "channel-actor" } },
},
cfg,
});
await initSessionState({
ctx: {
RawBody: "own agent prompt",
@@ -1543,6 +1553,15 @@ describe("initSessionState RawBody", () => {
},
cfg,
});
await initSessionState({
ctx: {
RawBody: "delegated agent prompt",
ChatType: "direct",
SessionKey: "agent:main:delegated-agent-participant",
SessionCreation: { via: "spawn", actor: { type: "agent", id: "research" } },
},
cfg,
});
await vi.waitFor(() => {
const participants = listSessionParticipantsReadOnly({ agentId: "main", storePath });
@@ -1555,7 +1574,23 @@ describe("initSessionState RawBody", () => {
},
]);
expect(participants.get("agent:main:unknown-participant")).toBeUndefined();
expect(participants.get("agent:main:channel-created-participant")).toEqual([
{
actor: { type: "human", id: "channel-created-sender" },
firstPromptedAt: expect.any(Number),
lastPromptedAt: expect.any(Number),
source: "channel",
},
]);
expect(participants.get("agent:main:own-agent-participant")).toBeUndefined();
expect(participants.get("agent:main:delegated-agent-participant")).toEqual([
{
actor: { type: "agent", id: "research" },
firstPromptedAt: expect.any(Number),
lastPromptedAt: expect.any(Number),
source: "agent",
},
]);
});
});
+12 -5
View File
@@ -36,6 +36,7 @@ import {
import { sessionEntryForkedFromParent } from "../../config/sessions/session-entry-lineage.js";
import {
buildSessionCreationStamp,
resolveProfileParticipantIdFromSessionCreation,
type SessionCreatedActor,
} from "../../config/sessions/session-entry-provenance.js";
import { resolveSessionKey } from "../../config/sessions/session-key.js";
@@ -1047,13 +1048,19 @@ async function initSessionStateAttemptLocked(
sessionEntry = committed.sessionEntry;
sessionId = sessionEntry.sessionId;
if (!isSystemEvent && !isInterSession) {
const creationActor = ctx.SessionCreation?.actor;
const creation = ctx.SessionCreation;
const creationActor = creation?.actor;
const profileParticipantId = resolveProfileParticipantIdFromSessionCreation(creation);
const senderId = normalizeOptionalString(ctx.SenderId);
const participant:
| { actor: SessionCreatedActor & { id: string }; source: "profile" | "channel" }
| undefined =
creationActor?.id && (creationActor.type === "human" || creationActor.type === "agent")
? { actor: { ...creationActor, id: creationActor.id }, source: "profile" }
| { actor: SessionCreatedActor & { id: string }; source: "profile" | "channel" | "agent" }
| undefined = profileParticipantId
? { actor: { type: "human", id: profileParticipantId }, source: "profile" }
: creationActor?.type === "agent" && creationActor.id
? {
actor: { ...creationActor, id: creationActor.id },
source: "agent",
}
: senderId
? { actor: { type: "human", id: senderId }, source: "channel" }
: undefined;
@@ -8,7 +8,11 @@ import {
resolveSqliteReadScope,
toDatabaseOptions,
} from "./session-accessor.sqlite-scope.js";
import type { SessionCreatedActor, SessionParticipantSource } from "./session-entry-provenance.js";
import {
MAX_SESSION_PARTICIPANTS,
type SessionCreatedActor,
type SessionParticipantSource,
} from "./session-entry-provenance.js";
import type { SessionEntry } from "./types.js";
export type SessionParticipantRecord = {
@@ -18,6 +22,25 @@ export type SessionParticipantRecord = {
source?: SessionParticipantSource;
};
export function resolveBoundedProfileParticipantSnapshot(
records: readonly SessionParticipantRecord[],
currentProfileId?: string,
): { profileIds: string[]; incomplete: boolean } {
const profileIds = new Set(
records.flatMap((record) =>
record.actor.type === "human" && record.source === "profile" ? [record.actor.id] : [],
),
);
const current = currentProfileId?.trim();
if (current && !profileIds.has(current) && records.length < MAX_SESSION_PARTICIPANTS) {
profileIds.add(current);
}
return {
profileIds: [...profileIds],
incomplete: records.length >= MAX_SESSION_PARTICIPANTS,
};
}
function projectParticipantRow(row: {
actor_id: string;
actor_source?: string | null;
@@ -135,11 +158,16 @@ export function projectSqliteSessionParticipantsBatch(
export function listSessionParticipantsReadOnly(scope: {
agentId: string;
env?: NodeJS.ProcessEnv;
sessionKey?: string;
storePath?: string;
}): Map<string, SessionParticipantRecord[]> {
const resolved = resolveSqliteReadScope(scope);
const result = withOpenClawAgentDatabaseReadOnly(
(database) => participantRecordsBySessionKey(database.db),
(database) =>
participantRecordsBySessionKey(
database.db,
scope.sessionKey ? [scope.sessionKey] : undefined,
),
toDatabaseOptions(resolved),
);
return result.found ? result.value : new Map();
@@ -18,12 +18,13 @@ import {
toDatabaseOptions,
} from "./session-accessor.sqlite-scope.js";
import {
MAX_SESSION_PARTICIPANTS,
mergeSessionParticipantSource,
type SessionCreatedActor,
type SessionParticipantSource,
} from "./session-entry-provenance.js";
export const MAX_SESSION_PARTICIPANTS = 32;
export { MAX_SESSION_PARTICIPANTS };
export type RecordSessionParticipantResult = "inserted" | "updated" | "capped";
@@ -8,6 +8,7 @@ export type SessionCreatedActor = {
};
export type SessionParticipantSource = "profile" | "channel" | "agent";
export const MAX_SESSION_PARTICIPANTS = 32;
export type SessionParticipant = SessionCreatedActor & {
/** Identity namespace recorded at the participant producer; absent means unknown legacy data. */
@@ -44,6 +45,22 @@ export type SessionCreatedVia =
| "plugin" // trusted plugin runtime creation
| "internal"; // internal/hidden sessions (internal-session-effects, voice bare rows)
export function resolveProfileParticipantIdFromSessionCreation(
creation:
| {
via: SessionCreatedVia;
actor?: SessionCreatedActor;
}
| undefined,
): string | undefined {
const profileId = creation?.actor?.id?.trim();
return creation?.actor?.type === "human" &&
(creation.via === "operator" || creation.via === "run") &&
profileId
? profileId
: undefined;
}
// Return shape mirrors the SessionEntry creation fields as a leaf contract;
// types.ts imports from here, never the reverse (madge cycle guard).
export function buildSessionCreationStamp(params: {
@@ -2,6 +2,7 @@ import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/i
import { getAdmittedRunDelegatedAuthority } from "../../agents/admitted-run-context.js";
import { attachAgentCommandAdmissionFacts } from "../../agents/agent-command-admission-facts.js";
import type { AgentRunTerminalOutcome } from "../../agents/agent-run-terminal-outcome.js";
import { prepareGitCoauthorAttribution } from "../../agents/git-coauthor-attribution.js";
import { repairMainSessionRecoveryMutation } from "../../agents/main-session-recovery/main-session-recovery-lifecycle.js";
import { scheduleMainSessionRecoveryPendingTarget } from "../../agents/main-session-recovery/main-session-recovery-owner-release.js";
import {
@@ -66,6 +67,7 @@ export function startAgentRunExecution(params: {
resolvedSessionKey?: string;
requestedSessionKey?: string;
resolvedSessionId?: string;
storePath?: string;
agentId?: string;
activeSessionAgentId: string;
delivery: AgentDeliveryPhaseResult;
@@ -290,6 +292,13 @@ export function startAgentRunExecution(params: {
modelRun: params.request.modelRun === true,
promptMode: params.request.promptMode,
extraSystemPrompt: params.request.extraSystemPrompt,
gitCoauthorAttribution: prepareGitCoauthorAttribution({
agentId: params.activeSessionAgentId,
config: params.cfgForAgent ?? params.cfg,
currentProfileId: params.client?.authenticatedUserProfile?.profileId,
sessionKey: params.resolvedSessionKey,
storePath: params.storePath,
}),
bootstrapContextMode: params.request.bootstrapContextMode,
bootstrapContextRunKind: params.effectiveBootstrapContextRunKind,
toolsAllow: params.restoredCronContinuation?.toolsAllow,
@@ -24,6 +24,7 @@ import {
} from "../../cron/scheduled-tool-policy.js";
import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { recordSessionParticipantBestEffort } from "../../sessions/session-participant-recording.js";
import { recordSessionCreated } from "../../sessions/session-state-events.js";
import { getGeneratedMediaTaskIdsForSessionKey } from "../../tasks/task-status-access.js";
import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js";
@@ -464,6 +465,15 @@ export async function persistAgentSessionPhase(params: {
entry: sessionEntry,
});
}
if (params.creation.actor?.type === "human" && params.creation.actor.id) {
recordSessionParticipantBestEffort({
actor: { type: "human", id: params.creation.actor.id },
agentId: params.sessionAgentId,
sessionKey: params.canonicalSessionKey,
source: "profile",
storePath: params.storePath,
});
}
if (isNewSession && params.entry?.sessionId && resolvedSessionId !== params.entry.sessionId) {
supersededSessionId = params.entry.sessionId;
}
@@ -250,6 +250,7 @@ export function createAgentTurnService({
let supersededSessionId: string | undefined;
let skipAgentInitialSessionTouch = false;
let pendingChatRun: { sessionKey: string; agentId?: string } | undefined;
let resolvedStorePath: string | undefined;
let admittedSessionId = resolvedSessionId ?? runId;
const admissionController = createAgentAdmissionController({
cfg,
@@ -343,6 +344,7 @@ export function createAgentTurnService({
failedSessionTranscriptMissing: resolveFailedSessionTranscriptMissingForEntry,
} = preparedSession;
cfgForAgent = cfgLocal;
resolvedStorePath = storePath;
// Authorize the canonical session the run will actually target — covering
// keyless requests whose default/effective session is resolved only here —
// before any run side effects (admission, dispatch).
@@ -579,6 +581,7 @@ export function createAgentTurnService({
resolvedSessionKey,
requestedSessionKey,
resolvedSessionId,
storePath: resolvedStorePath,
agentId,
activeSessionAgentId,
delivery,
+1
View File
@@ -58,6 +58,7 @@ describe("buildControlUiCspHeader", () => {
"data:",
"blob:",
"https://gravatar.com",
"https://avatars.githubusercontent.com",
]);
expect(imgSrc?.split(" ")).not.toContain("https:");
});
+1 -1
View File
@@ -90,7 +90,7 @@ export function buildControlUiCspHeader(opts?: {
"frame-src 'self' http: https:",
`script-src ${scriptTokens.join(" ")}`,
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: blob: https://gravatar.com",
"img-src 'self' data: blob: https://gravatar.com https://avatars.githubusercontent.com",
"media-src 'self' data: blob:",
"font-src 'self' https://fonts.gstatic.com",
"worker-src 'self'",
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import { ControlUiGitHubError } from "./control-ui-github-api.js";
import { resolveGitHubUserIdentity } from "./github-user-identity.js";
function githubResponse(body: unknown, status = 200, headers: Record<string, string> = {}) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json", ...headers },
});
}
describe("resolveGitHubUserIdentity", () => {
it("resolves the canonical public account without authentication", async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValue(githubResponse({ id: 583231, login: "OctoCat" }));
await expect(resolveGitHubUserIdentity("octocat", fetchMock)).resolves.toEqual({
accountId: 583231,
login: "OctoCat",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/users/octocat",
expect.objectContaining({ redirect: "manual", signal: expect.any(AbortSignal) }),
);
const headers = fetchMock.mock.calls[0]?.[1]?.headers;
expect(headers).toMatchObject({
Accept: "application/vnd.github+json",
"User-Agent": "OpenClaw-Control-UI",
"X-GitHub-Api-Version": "2022-11-28",
});
expect(headers).not.toHaveProperty("Authorization");
});
it.each([
{
name: "not found",
response: githubResponse({ message: "Not Found" }, 404),
statusCode: 404,
},
{
name: "rate limited",
response: githubResponse({ message: "rate limit" }, 403, { "x-ratelimit-remaining": "0" }),
statusCode: 429,
},
{ name: "malformed", response: githubResponse({ id: "583231" }), statusCode: 502 },
])("maps a $name response", async ({ response, statusCode }) => {
await expect(
resolveGitHubUserIdentity("octocat", vi.fn<typeof fetch>().mockResolvedValue(response)),
).rejects.toMatchObject({ statusCode } satisfies Partial<ControlUiGitHubError>);
});
it("maps network failures and rejects invalid usernames before fetch", async () => {
const fetchMock = vi.fn<typeof fetch>().mockRejectedValue(new Error("network down"));
await expect(resolveGitHubUserIdentity("octocat", fetchMock)).rejects.toMatchObject({
statusCode: 502,
} satisfies Partial<ControlUiGitHubError>);
await expect(resolveGitHubUserIdentity("bad/name", fetchMock)).rejects.toThrow(
"GitHub username is invalid",
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeGitHubLogin } from "../utils/github-login.js";
import {
ControlUiGitHubError,
GITHUB_API_ORIGIN,
fetchGitHubJson,
optionalNumber,
readOptionalGitHubString,
} from "./control-ui-github-api.js";
type ResolvedGitHubUserIdentity = { accountId: number; login: string };
export async function resolveGitHubUserIdentity(
username: string,
fetchImpl: typeof fetch = fetch,
): Promise<ResolvedGitHubUserIdentity> {
const requestedLogin = normalizeGitHubLogin(username);
if (!requestedLogin) {
throw new TypeError("GitHub username is invalid");
}
let payload: unknown;
try {
payload = await fetchGitHubJson(
`${GITHUB_API_ORIGIN}/users/${encodeURIComponent(requestedLogin)}`,
fetchImpl,
undefined,
);
} catch (error) {
if (error instanceof ControlUiGitHubError) {
throw error;
}
throw new ControlUiGitHubError(502, "GitHub user lookup failed");
}
if (!isRecord(payload)) {
throw new ControlUiGitHubError(502, "GitHub user response was not an object");
}
const accountId = optionalNumber(payload, "id");
const login = normalizeGitHubLogin(readOptionalGitHubString(payload, "login") ?? "");
if (
typeof accountId !== "number" ||
!Number.isSafeInteger(accountId) ||
accountId <= 0 ||
!login
) {
throw new ControlUiGitHubError(502, "GitHub user response omitted a valid id or login");
}
return { accountId, login };
}
+2
View File
@@ -99,6 +99,8 @@ describe("method scope resolution", () => {
["projects.list", ["operator.read"]],
["users.prefs.get", ["operator.read"]],
["users.prefs.set", ["operator.write"]],
["users.setGitHubIdentity", ["operator.write"]],
["users.clearGitHubIdentity", ["operator.write"]],
["projects.register", ["operator.admin"]],
["projects.remove", ["operator.admin"]],
["projects.add", ["operator.write"]],
@@ -116,6 +116,8 @@ const CURRENT_TRAIN_METHODS = [
"progressCard.put",
"tools.github.status",
"tools.github.configure",
"users.setGitHubIdentity",
"users.clearGitHubIdentity",
] as const;
describe("core gateway method release trains", () => {
+2
View File
@@ -553,6 +553,8 @@ const CORE_GATEWAY_METHOD_SPECS = [
{ controlPlaneWrite: true },
],
["diagnostics.lanes", "diagnostics", "operator.read", "2026.8"],
["users.setGitHubIdentity", "users", "operator.write", "2026.8"],
["users.clearGitHubIdentity", "users", "operator.write", "2026.8"],
] as const satisfies readonly CoreGatewayMethodSpecRow[];
export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>;
+12 -2
View File
@@ -72,7 +72,7 @@ describe("listGatewayMethods", () => {
});
it("appends new methods after model probing without shifting older method indices", () => {
expect(listGatewayMethods().slice(-60)).toEqual([
expect(listGatewayMethods().slice(-62)).toEqual([
"models.probe",
"migrations.memory.plan",
"migrations.memory.apply",
@@ -133,6 +133,8 @@ describe("listGatewayMethods", () => {
"tools.github.status",
"tools.github.configure",
"diagnostics.lanes",
"users.setGitHubIdentity",
"users.clearGitHubIdentity",
]);
const methods = listGatewayMethods();
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
@@ -238,7 +240,7 @@ describe("listGatewayMethods", () => {
"exec.approval.get",
]);
expect(methods).toContain("tts.speak");
expect(coreMethods.slice(-67)).toEqual([
expect(coreMethods.slice(-69)).toEqual([
"sessions.catalog.continue",
"sessions.catalog.archive",
"approval.get",
@@ -306,6 +308,8 @@ describe("listGatewayMethods", () => {
"tools.github.status",
"tools.github.configure",
"diagnostics.lanes",
"users.setGitHubIdentity",
"users.clearGitHubIdentity",
]);
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1);
@@ -346,6 +350,12 @@ describe("listGatewayMethods", () => {
expect(methods.indexOf("sessions.assignOwner")).toBe(methods.indexOf("sessions.move") + 1);
expect(methods.indexOf("progressCard.get")).toBe(methods.indexOf("sessions.assignOwner") + 1);
expect(methods.indexOf("progressCard.put")).toBe(methods.indexOf("progressCard.get") + 1);
expect(methods.indexOf("users.setGitHubIdentity")).toBe(
methods.indexOf("diagnostics.lanes") + 1,
);
expect(methods.indexOf("users.clearGitHubIdentity")).toBe(
methods.indexOf("users.setGitHubIdentity") + 1,
);
});
it("advertises the versioned Talk session RPCs", () => {
+60
View File
@@ -5,6 +5,8 @@ import {
validateUsersSelfResult,
validateUsersSetAvatarResult,
validateUsersSetDisplayNameResult,
validateUsersSetGitHubIdentityResult,
validateUsersClearGitHubIdentityResult,
} from "../../../packages/gateway-protocol/src/index.js";
import { usersHandlers } from "./users.js";
@@ -12,12 +14,16 @@ const linkEmail = vi.hoisted(() => vi.fn());
const listProfiles = vi.hoisted(() => vi.fn());
const setAvatar = vi.hoisted(() => vi.fn());
const setDisplayName = vi.hoisted(() => vi.fn());
const setGitHubIdentity = vi.hoisted(() => vi.fn());
const clearGitHubIdentity = vi.hoisted(() => vi.fn());
const resolveGitHubUserIdentity = vi.hoisted(() => vi.fn());
const ensureProfileForEmail = vi.hoisted(() => vi.fn());
const getUserProfileDisplay = vi.hoisted(() => vi.fn());
const getUserProfileListItem = vi.hoisted(() => vi.fn());
const resolveUserProfileId = vi.hoisted(() => vi.fn());
vi.mock("../../state/user-profiles.js", () => ({
clearGitHubIdentity,
ensureProfileForEmail,
getUserProfileDisplay,
getUserProfileListItem,
@@ -26,9 +32,13 @@ vi.mock("../../state/user-profiles.js", () => ({
resolveUserProfileId,
setAvatar,
setDisplayName,
setGitHubIdentity,
UserProfileGitHubIdentityConflictError: class UserProfileGitHubIdentityConflictError extends Error {},
UserProfileNotFoundError: class UserProfileNotFoundError extends Error {},
}));
vi.mock("../github-user-identity.js", () => ({ resolveGitHubUserIdentity }));
async function runUsersHandler(
method: keyof typeof usersHandlers,
params: object,
@@ -52,6 +62,7 @@ describe("users gateway methods", () => {
createdAt: 1,
updatedAt: 1,
emails: ["ada@example.com"],
githubIdentity: null,
hasAvatar: false,
};
const adminClient = { connect: { scopes: ["operator.admin"] } };
@@ -69,6 +80,9 @@ describe("users gateway methods", () => {
listProfiles.mockReset();
setAvatar.mockReset();
setDisplayName.mockReset();
setGitHubIdentity.mockReset();
clearGitHubIdentity.mockReset();
resolveGitHubUserIdentity.mockReset();
getUserProfileDisplay.mockReturnValue({
id: profile.id,
displayName: profile.displayName,
@@ -217,6 +231,52 @@ describe("users gateway methods", () => {
});
});
it("sets and clears only the authenticated user's GitHub identity", async () => {
ensureProfileForEmail.mockReturnValue({ id: profile.id });
resolveUserProfileId.mockReturnValue(profile.id);
resolveGitHubUserIdentity.mockResolvedValue({ accountId: 583231, login: "octocat" });
const linked = {
...profile,
githubIdentity: {
login: "octocat",
profileUrl: "https://github.com/octocat",
avatarUrl: "https://avatars.githubusercontent.com/u/583231?v=4",
},
};
setGitHubIdentity.mockReturnValue(linked);
clearGitHubIdentity.mockReturnValue(profile);
const setResponse = await runUsersHandler(
"users.setGitHubIdentity",
{ username: "octocat" },
selfClient,
);
const clearResponse = await runUsersHandler("users.clearGitHubIdentity", {}, selfClient);
expect(resolveGitHubUserIdentity).toHaveBeenCalledWith("octocat");
expect(setGitHubIdentity).toHaveBeenCalledWith(profile.id, {
accountId: 583231,
login: "octocat",
});
expect(validateUsersSetGitHubIdentityResult(setResponse.mock.calls[0]?.[1])).toBe(true);
expect(validateUsersClearGitHubIdentityResult(clearResponse.mock.calls[0]?.[1])).toBe(true);
});
it("rejects GitHub identity changes without an authenticated profile", async () => {
const anonymous = { connect: { scopes: ["operator.write"] } };
for (const [method, params] of [
["users.setGitHubIdentity", { username: "octocat" }],
["users.clearGitHubIdentity", {}],
] as const) {
expect(await runUsersHandler(method, params, anonymous)).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "FORBIDDEN" }),
);
}
expect(resolveGitHubUserIdentity).not.toHaveBeenCalled();
});
it("returns protocol-complete avatar mutations", async () => {
const firstProfile = {
...profile,
+83
View File
@@ -5,16 +5,19 @@ import {
errorShape,
formatValidationErrors,
validateUsersLinkEmailParams,
validateUsersClearGitHubIdentityParams,
validateUsersListParams,
validateUsersPrefsGetParams,
validateUsersPrefsSetParams,
validateUsersSelfParams,
validateUsersSetAvatarParams,
validateUsersSetDisplayNameParams,
validateUsersSetGitHubIdentityParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { getUserPreferences, setUserPreferences } from "../../state/user-preferences.js";
import {
clearGitHubIdentity,
ensureProfileForEmail,
getUserProfileDisplay,
getUserProfileListItem,
@@ -23,8 +26,12 @@ import {
resolveUserProfileId,
setAvatar,
setDisplayName,
setGitHubIdentity,
UserProfileGitHubIdentityConflictError,
UserProfileNotFoundError,
} from "../../state/user-profiles.js";
import { ControlUiGitHubError } from "../control-ui-github-api.js";
import { resolveGitHubUserIdentity } from "../github-user-identity.js";
import { ADMIN_SCOPE } from "../operator-scopes.js";
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js";
@@ -60,12 +67,35 @@ function invalidParams(name: string, errors: Parameters<typeof formatValidationE
}
function profileError(error: unknown) {
if (error instanceof UserProfileGitHubIdentityConflictError) {
return errorShape(ErrorCodes.INVALID_REQUEST, error.message);
}
if (error instanceof UserProfileNotFoundError) {
return errorShape(ErrorCodes.INVALID_REQUEST, error.message);
}
return errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error));
}
function githubLookupError(error: unknown) {
if (error instanceof TypeError) {
return errorShape(ErrorCodes.INVALID_REQUEST, error.message);
}
if (error instanceof ControlUiGitHubError) {
if (error.statusCode === 404) {
return errorShape(ErrorCodes.INVALID_REQUEST, "GitHub user not found");
}
if (error.statusCode === 429) {
return errorShape(ErrorCodes.UNAVAILABLE, "GitHub rate limit reached; try again later", {
retryable: true,
});
}
return errorShape(ErrorCodes.UNAVAILABLE, "GitHub user lookup is unavailable", {
retryable: true,
});
}
return profileError(error);
}
function resolveAuthenticatedProfileId(
client: GatewayRequestHandlerOptions["client"],
): string | undefined {
@@ -320,4 +350,57 @@ export const usersHandlers: GatewayRequestHandlers = {
respond(false, undefined, profileError(error));
}
},
"users.setGitHubIdentity": async ({ client, context, params, respond }) => {
if (!validateUsersSetGitHubIdentityParams(params)) {
respond(
false,
undefined,
invalidParams("users.setGitHubIdentity", validateUsersSetGitHubIdentityParams.errors),
);
return;
}
const profileId = resolveAuthenticatedProfileId(client);
if (!profileId) {
respond(
false,
undefined,
errorShape(ErrorCodes.FORBIDDEN, "GitHub identity changes require an authenticated user"),
);
return;
}
try {
const identity = await resolveGitHubUserIdentity(params.username);
const profile = setGitHubIdentity(profileId, identity);
refreshConnectedProfile(context, profile);
respond(true, { profile });
} catch (error) {
respond(false, undefined, githubLookupError(error));
}
},
"users.clearGitHubIdentity": ({ client, context, params, respond }) => {
if (!validateUsersClearGitHubIdentityParams(params)) {
respond(
false,
undefined,
invalidParams("users.clearGitHubIdentity", validateUsersClearGitHubIdentityParams.errors),
);
return;
}
const profileId = resolveAuthenticatedProfileId(client);
if (!profileId) {
respond(
false,
undefined,
errorShape(ErrorCodes.FORBIDDEN, "GitHub identity changes require an authenticated user"),
);
return;
}
try {
const profile = clearGitHubIdentity(profileId);
refreshConnectedProfile(context, profile);
respond(true, { profile });
} catch (error) {
respond(false, undefined, profileError(error));
}
},
};
+252
View File
@@ -0,0 +1,252 @@
import type { DatabaseSync } from "node:sqlite";
import type { UserProfileGitHubIdentity } from "../../packages/gateway-protocol/src/schema/users.js";
import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync } from "../infra/kysely-sync.js";
import { normalizeGitHubLogin } from "../utils/github-login.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "./openclaw-state-db.js";
import {
requireResolvedUserProfileById,
selectResolvedUserProfileById,
userProfilesDb,
} from "./user-profiles-internal.js";
import { ensureUserProfilesSchema } from "./user-profiles-schema.js";
const GITHUB_ATTRIBUTION_PROVIDER = "github-attribution";
type UserProfileGitHubAttributionIdentity = {
accountId: number;
login: string;
};
export class UserProfileGitHubIdentityConflictError extends Error {
constructor() {
super("this GitHub account is already linked to another OpenClaw profile");
this.name = "UserProfileGitHubIdentityConflictError";
}
}
function parseStoredGitHubIdentity(row: {
subject: string | null | undefined;
canonical_login: string | null | undefined;
}): UserProfileGitHubAttributionIdentity | null {
const accountId = Number(row.subject);
const login = row.canonical_login ? normalizeGitHubLogin(row.canonical_login) : undefined;
return login && Number.isSafeInteger(accountId) && accountId > 0 ? { accountId, login } : null;
}
function toPublicGitHubIdentity(
identity: UserProfileGitHubAttributionIdentity | null,
): UserProfileGitHubIdentity | null {
if (!identity) {
return null;
}
return {
login: identity.login,
profileUrl: `https://github.com/${identity.login}`,
avatarUrl: `https://avatars.githubusercontent.com/u/${identity.accountId}?v=4`,
};
}
export function selectUserProfileGitHubIdentities(
db: DatabaseSync,
profileIds?: readonly string[],
): Map<string, UserProfileGitHubIdentity> {
let query = userProfilesDb(db)
.selectFrom("user_profile_identities")
.select(["profile_id", "subject", "canonical_login"])
.where("provider", "=", GITHUB_ATTRIBUTION_PROVIDER)
.where("canonical_login", "is not", null);
if (profileIds) {
query = query.where("profile_id", "in", [...profileIds]);
}
const rows = executeSqliteQuerySync(db, query).rows;
return new Map(
rows.flatMap((row) => {
const identity = toPublicGitHubIdentity(parseStoredGitHubIdentity(row));
return identity ? [[row.profile_id, identity] as const] : [];
}),
);
}
/** Resolves a bounded profile snapshot and its internal Git attribution in two batched reads. */
export function resolveUserProfileGitHubAttribution(
profileIds: readonly string[],
options: OpenClawStateDatabaseOptions = {},
): Map<string, UserProfileGitHubAttributionIdentity | null> {
if (profileIds.length === 0) {
return new Map();
}
const database = openOpenClawStateDatabase(options);
ensureUserProfilesSchema(options, database);
const { db } = database;
const kysely = userProfilesDb(db);
const sources = executeSqliteQuerySync(
db,
kysely
.selectFrom("user_profiles")
.select(["id", "merged_into"])
.where("id", "in", [...profileIds]),
).rows;
const canonicalBySource = new Map(
sources.map((profile) => [profile.id, profile.merged_into ?? profile.id] as const),
);
const canonicalIds = [...new Set(canonicalBySource.values())];
const canonicalRows = executeSqliteQuerySync(
db,
kysely
.selectFrom("user_profiles as profile")
.leftJoin("user_profile_identities as identity", (join) =>
join
.onRef("identity.profile_id", "=", "profile.id")
.on("identity.provider", "=", GITHUB_ATTRIBUTION_PROVIDER),
)
.select([
"profile.id as profile_id",
"identity.subject as subject",
"identity.canonical_login as canonical_login",
])
.where("profile.id", "in", canonicalIds),
).rows;
const identityByCanonical = new Map(
canonicalRows.map((row) => [row.profile_id, parseStoredGitHubIdentity(row)] as const),
);
return new Map(
[...canonicalBySource].flatMap(([sourceId, canonicalId]) =>
identityByCanonical.has(canonicalId)
? [[sourceId, identityByCanonical.get(canonicalId) ?? null] as const]
: [],
),
);
}
export function mergeUserProfileGitHubIdentity(
db: DatabaseSync,
sourceProfileIds: readonly string[],
targetProfileId: string,
): void {
const kysely = userProfilesDb(db);
if (!selectUserProfileGitHubIdentities(db, [targetProfileId]).has(targetProfileId)) {
return;
}
executeSqliteQuerySync(
db,
kysely
.deleteFrom("user_profile_identities")
.where("provider", "=", GITHUB_ATTRIBUTION_PROVIDER)
.where("profile_id", "in", sourceProfileIds)
.where("canonical_login", "is not", null),
);
}
function mutateUserProfileGitHubIdentity(
profileId: string,
options: OpenClawStateDatabaseOptions,
operationLabel: string,
mutate: (db: DatabaseSync, canonicalProfileId: string, now: number) => void,
): string {
ensureUserProfilesSchema(options);
return runOpenClawStateWriteTransaction(
({ db }) => {
const canonicalProfileId = requireResolvedUserProfileById(db, profileId).id;
const now = Date.now();
mutate(db, canonicalProfileId, now);
executeSqliteQuerySync(
db,
userProfilesDb(db)
.updateTable("user_profiles")
.set({ updated_at: now })
.where("id", "=", canonicalProfileId),
);
return canonicalProfileId;
},
options,
{ operationLabel },
);
}
export function setUserProfileGitHubIdentity(
profileId: string,
identity: { accountId: number; login: string },
options: OpenClawStateDatabaseOptions,
): string {
if (!Number.isSafeInteger(identity.accountId) || identity.accountId <= 0) {
throw new TypeError("GitHub account id must be a positive safe integer");
}
const login = normalizeGitHubLogin(identity.login);
if (!login) {
throw new TypeError("GitHub login is invalid");
}
return mutateUserProfileGitHubIdentity(
profileId,
options,
"user-profiles.set-github-identity",
(db, canonicalProfileId, now) => {
const kysely = userProfilesDb(db);
const subject = String(identity.accountId);
const existing = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("user_profile_identities")
.select("profile_id")
.where("provider", "=", GITHUB_ATTRIBUTION_PROVIDER)
.where("subject", "=", subject),
);
if (
existing &&
selectResolvedUserProfileById(db, existing.profile_id)?.id !== canonicalProfileId
) {
throw new UserProfileGitHubIdentityConflictError();
}
executeSqliteQuerySync(
db,
kysely
.deleteFrom("user_profile_identities")
.where("provider", "=", GITHUB_ATTRIBUTION_PROVIDER)
.where("profile_id", "=", canonicalProfileId)
.where("canonical_login", "is not", null),
);
executeSqliteQuerySync(
db,
kysely
.insertInto("user_profile_identities")
.values({
provider: GITHUB_ATTRIBUTION_PROVIDER,
subject,
profile_id: canonicalProfileId,
canonical_login: login,
created_at: now,
})
.onConflict((conflict) =>
conflict.columns(["provider", "subject"]).doUpdateSet({
profile_id: canonicalProfileId,
canonical_login: login,
}),
),
);
},
);
}
export function clearUserProfileGitHubIdentity(
profileId: string,
options: OpenClawStateDatabaseOptions,
): string {
return mutateUserProfileGitHubIdentity(
profileId,
options,
"user-profiles.clear-github-identity",
(db, canonicalProfileId) => {
executeSqliteQuerySync(
db,
userProfilesDb(db)
.deleteFrom("user_profile_identities")
.where("provider", "=", GITHUB_ATTRIBUTION_PROVIDER)
.where("profile_id", "=", canonicalProfileId)
.where("canonical_login", "is not", null),
);
},
);
}
+80
View File
@@ -0,0 +1,80 @@
import { sql } from "kysely";
import { executeSqliteQuerySync } from "../infra/kysely-sync.js";
import { runSqliteDeferredTransactionSync } from "../infra/sqlite-transaction.js";
import {
openOpenClawStateDatabase,
type OpenClawStateDatabaseOptions,
} from "./openclaw-state-db.js";
import { selectUserProfileGitHubIdentities } from "./user-profile-github-identity.js";
import { normalizeUserProfileAvatarMime, userProfilesDb } from "./user-profiles-internal.js";
import { ensureUserProfilesSchema } from "./user-profiles-schema.js";
export function listProfiles(options: OpenClawStateDatabaseOptions = {}) {
ensureUserProfilesSchema(options);
const database = openOpenClawStateDatabase(options);
return runSqliteDeferredTransactionSync(
database.db,
() => {
const kysely = userProfilesDb(database.db);
const profiles = executeSqliteQuerySync(
database.db,
kysely
.selectFrom("user_profiles")
.select([
"id",
"display_name",
"avatar_mime",
"merged_into",
"created_at",
"updated_at",
sql`CASE WHEN avatar IS NULL THEN 0 ELSE 1 END`.as("has_avatar"),
])
.orderBy("created_at", "asc")
.orderBy("id", "asc"),
).rows;
const emails = executeSqliteQuerySync(
database.db,
kysely
.selectFrom("user_profile_emails")
.select(["profile_id", "email"])
.orderBy("email", "asc"),
).rows;
const githubIdentities = selectUserProfileGitHubIdentities(database.db);
const emailsByProfile = new Map<string, string[]>();
for (const email of emails) {
const list = emailsByProfile.get(email.profile_id) ?? [];
list.push(email.email);
emailsByProfile.set(email.profile_id, list);
}
return profiles.map((profile) => ({
id: profile.id,
displayName: profile.display_name,
avatarMime: normalizeUserProfileAvatarMime(profile.avatar_mime),
mergedInto: profile.merged_into,
createdAt: profile.created_at,
updatedAt: profile.updated_at,
emails: emailsByProfile.get(profile.id) ?? [],
githubIdentity: githubIdentities.get(profile.id) ?? null,
hasAvatar: profile.has_avatar === 1,
}));
},
{ databaseLabel: database.path, operationLabel: "user-profiles.list" },
);
}
/** True when session-sharing policy can distinguish at least two durable people. */
export function hasMultipleSessionSharingIdentities(
options: OpenClawStateDatabaseOptions = {},
): boolean {
ensureUserProfilesSchema(options);
const { db } = openOpenClawStateDatabase(options);
const profiles = executeSqliteQuerySync(
db,
userProfilesDb(db)
.selectFrom("user_profiles")
.select("id")
.where("merged_into", "is", null)
.limit(2),
).rows;
return profiles.length >= 2;
}
+78
View File
@@ -0,0 +1,78 @@
import type { DatabaseSync } from "node:sqlite";
import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import {
openOpenClawStateDatabase,
type OpenClawStateDatabaseOptions,
} from "./openclaw-state-db.js";
import {
ensureUserProfilesSchema,
type UserProfilesDatabase,
UserProfileNotFoundError,
} from "./user-profiles-schema.js";
import {
USER_PROFILE_AVATAR_MIME_TYPES,
type UserProfileAvatarMime,
} from "./user-profiles-tailscale-avatar.js";
export type UserProfileRow = UserProfilesDatabase["user_profiles"];
type UserProfileAvatar = {
bytes: Uint8Array;
mime: UserProfileAvatarMime;
sha256: string;
updatedAt: number;
};
export function userProfilesDb(db: DatabaseSync) {
return getNodeSqliteKysely<UserProfilesDatabase>(db);
}
export function normalizeUserProfileAvatarMime(value: string | null): UserProfileAvatarMime | null {
return USER_PROFILE_AVATAR_MIME_TYPES.find((candidate) => candidate === value) ?? null;
}
function selectUserProfileById(db: DatabaseSync, profileId: string): UserProfileRow | undefined {
return executeSqliteQueryTakeFirstSync(
db,
userProfilesDb(db).selectFrom("user_profiles").selectAll().where("id", "=", profileId),
);
}
export function selectResolvedUserProfileById(
db: DatabaseSync,
profileId: string,
): UserProfileRow | undefined {
const profile = selectUserProfileById(db, profileId);
if (!profile?.merged_into) {
return profile;
}
// Merge writers repoint aliases and existing tombstones, so durable profile
// references need exactly one hop to reach the canonical row.
return selectUserProfileById(db, profile.merged_into) ?? profile;
}
export function requireResolvedUserProfileById(
db: DatabaseSync,
profileId: string,
): UserProfileRow {
const profile = selectResolvedUserProfileById(db, profileId);
if (!profile) {
throw new UserProfileNotFoundError(profileId);
}
return profile;
}
export function formatUserProfileAvatarEtag(sha256: string, mime: UserProfileAvatarMime): string {
return `"${sha256}-${mime.slice("image/".length)}"`;
}
export function getProfileAvatar(
profileId: string,
options: OpenClawStateDatabaseOptions = {},
): UserProfileAvatar | undefined {
ensureUserProfilesSchema(options);
const profile = selectResolvedUserProfileById(openOpenClawStateDatabase(options).db, profileId);
const mime = normalizeUserProfileAvatarMime(profile?.avatar_mime ?? null);
return profile?.avatar && mime && profile.avatar_sha256
? { bytes: profile.avatar, mime, sha256: profile.avatar_sha256, updatedAt: profile.updated_at }
: undefined;
}
+59 -1
View File
@@ -1,6 +1,14 @@
import type { DatabaseSync } from "node:sqlite";
import { ensureColumn } from "./openclaw-state-db-schema-helpers.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "./openclaw-state-db.js";
// Canonical additive schema for durable user profiles. Kept feature-local so
// ordinary shared-state opens do not create identity tables until they are used.
export const USER_PROFILES_SCHEMA_SQL = `
const USER_PROFILES_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS user_profiles (
id TEXT NOT NULL PRIMARY KEY,
display_name TEXT,
@@ -25,6 +33,7 @@ CREATE TABLE IF NOT EXISTS user_profile_identities (
provider TEXT NOT NULL,
subject TEXT NOT NULL,
profile_id TEXT NOT NULL,
canonical_login TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (provider, subject)
) STRICT;
@@ -32,3 +41,52 @@ CREATE TABLE IF NOT EXISTS user_profile_identities (
CREATE INDEX IF NOT EXISTS idx_user_profile_identities_profile_id
ON user_profile_identities(profile_id);
`;
export type UserProfilesDatabase = {
user_profiles: {
id: string;
display_name: string | null;
avatar: Uint8Array | null;
avatar_mime: string | null;
avatar_sha256: string | null;
merged_into: string | null;
created_at: number;
updated_at: number;
};
user_profile_emails: { email: string; profile_id: string; created_at: number };
user_profile_identities: {
provider: string;
subject: string;
profile_id: string;
canonical_login: string | null;
created_at: number;
};
};
export class UserProfileNotFoundError extends Error {
constructor(profileId: string) {
super(`user profile not found: ${profileId}`);
this.name = "UserProfileNotFoundError";
}
}
const ensuredDatabases = new WeakSet<DatabaseSync>();
export function ensureUserProfilesSchema(
options: OpenClawStateDatabaseOptions,
database = openOpenClawStateDatabase(options),
): void {
if (ensuredDatabases.has(database.db)) {
return;
}
runOpenClawStateWriteTransaction(
({ db }) => {
db.exec(USER_PROFILES_SCHEMA_SQL); // sqlite-allow-raw -- Canonical feature-local additive DDL.
ensureColumn(db, "user_profile_identities", "canonical_login TEXT");
},
options,
{ operationLabel: "user-profiles.schema.ensure" },
);
// A rolled-back ensure must retry rather than caching a missing table/column.
ensuredDatabases.add(database.db);
}
@@ -10,8 +10,8 @@ import {
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "./openclaw-state-db.js";
import { ensureUserProfilesSchema, type UserProfilesDatabase } from "./user-profiles-schema.js";
import { classifyTailscaleLogin } from "./user-profiles-tailscale-login.js";
import { ensureUserProfilesSchema, type UserProfilesDatabase } from "./user-profiles.js";
type UserProfileIdentityMigrationResult = {
changes: string[];
@@ -57,6 +57,7 @@ export function migrateLegacyTailscaleProfileIdentities(
provider: row.provider,
subject: row.subject,
profile_id: row.profile_id,
canonical_login: null,
created_at: row.created_at,
})
.onConflict((conflict) => conflict.columns(["provider", "subject"]).doNothing()),
+73 -1
View File
@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { tableExists } from "./openclaw-state-db-schema-helpers.js";
import { tableExists, tableHasColumn } from "./openclaw-state-db-schema-helpers.js";
import {
OPENCLAW_STATE_SCHEMA_VERSION,
closeOpenClawStateDatabaseForTest,
@@ -11,6 +11,7 @@ import {
import { migrateLegacyTailscaleProfileIdentities } from "./user-profiles-tailscale-migration.js";
import {
adoptTailscaleProfileAvatar,
clearGitHubIdentity,
ensureProfileForEmail,
ensureProfileForTailscaleIdentity,
formatUserProfileAvatarEtag,
@@ -21,6 +22,8 @@ import {
resolveUserProfileId,
setAvatar,
setDisplayName,
setGitHubIdentity,
UserProfileGitHubIdentityConflictError,
} from "./user-profiles.js";
const statePaths: string[] = [];
@@ -108,6 +111,75 @@ describe("user profiles", () => {
).toEqual([{ provider: "github", subject: "ada", profile_id: first.id }]);
});
it("lazily adds canonical GitHub login storage without changing the schema version", () => {
const options = stateOptions();
const database = openOpenClawStateDatabase(options).db;
database.exec(`
CREATE TABLE user_profile_identities (
provider TEXT NOT NULL,
subject TEXT NOT NULL,
profile_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (provider, subject)
) STRICT;
`);
const versionBefore = database.prepare("PRAGMA user_version").get()?.user_version;
ensureProfileForEmail("ada@example.com", options);
expect(tableHasColumn(database, "user_profile_identities", "canonical_login")).toBe(true);
expect(database.prepare("PRAGMA user_version").get()?.user_version).toBe(versionBefore);
});
it("sets, changes, uniquely owns, and clears a derived GitHub attribution identity", () => {
const options = stateOptions();
const ada = ensureProfileForEmail("ada@example.com", options);
const grace = ensureProfileForEmail("grace@example.com", options);
const numericLogin = ensureProfileForTailscaleIdentity(
{ login: "583231@github", name: "Numeric Login" },
options,
);
expect(
setGitHubIdentity(ada.id, { accountId: 583231, login: "octocat" }, options),
).toMatchObject({
githubIdentity: {
login: "octocat",
profileUrl: "https://github.com/octocat",
avatarUrl: "https://avatars.githubusercontent.com/u/583231?v=4",
},
});
expect(() =>
setGitHubIdentity(grace.id, { accountId: 583231, login: "octocat" }, options),
).toThrow(UserProfileGitHubIdentityConflictError);
expect(
setGitHubIdentity(ada.id, { accountId: 9919, login: "Ada-L" }, options).githubIdentity,
).toMatchObject({ login: "Ada-L" });
expect(clearGitHubIdentity(ada.id, options).githubIdentity).toBeNull();
expect(ensureProfileForTailscaleIdentity({ login: "583231@github" }, options).id).toBe(
numericLogin.id,
);
});
it("moves GitHub attribution to the merge head while preserving a target link", () => {
const options = stateOptions();
const source = ensureProfileForEmail("source@example.com", options);
const target = ensureProfileForEmail("target@example.com", options);
setGitHubIdentity(source.id, { accountId: 1, login: "source" }, options);
linkEmail("source@example.com", target.id, options);
expect(
listProfiles(options).find((profile) => profile.id === target.id)?.githubIdentity,
).toMatchObject({ login: "source" });
const next = ensureProfileForEmail("next@example.com", options);
setGitHubIdentity(next.id, { accountId: 2, login: "next" }, options);
linkEmail("target@example.com", next.id, options);
expect(
listProfiles(options).find((profile) => profile.id === next.id)?.githubIdentity,
).toMatchObject({ login: "next" });
});
it("keeps dotted Tailscale logins on the email alias path", () => {
const options = stateOptions();
+80 -197
View File
@@ -3,20 +3,30 @@ import { createHash } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import { err, ok, type Result } from "@openclaw/normalization-core/result";
import { sql } from "kysely";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import type { UserProfileGitHubIdentity } from "../../packages/gateway-protocol/src/schema/users.js";
import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync } from "../infra/kysely-sync.js";
import { generateSecureUuid } from "../infra/secure-random.js";
import { runSqliteDeferredTransactionSync } from "../infra/sqlite-transaction.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "./openclaw-state-db.js";
import { mergeUserPreferences } from "./user-preferences.js";
import { USER_PROFILES_SCHEMA_SQL } from "./user-profiles-schema.js";
import {
clearUserProfileGitHubIdentity,
mergeUserProfileGitHubIdentity,
selectUserProfileGitHubIdentities,
setUserProfileGitHubIdentity,
UserProfileGitHubIdentityConflictError,
} from "./user-profile-github-identity.js";
import {
normalizeUserProfileAvatarMime,
requireResolvedUserProfileById,
selectResolvedUserProfileById,
type UserProfileRow,
userProfilesDb,
} from "./user-profiles-internal.js";
import { ensureUserProfilesSchema, UserProfileNotFoundError } from "./user-profiles-schema.js";
import {
fetchTailscaleAvatar,
MAX_USER_PROFILE_AVATAR_BYTES,
@@ -29,6 +39,9 @@ import {
type TailscaleProfileIdentity,
} from "./user-profiles-tailscale-login.js";
export { formatUserProfileAvatarEtag, getProfileAvatar } from "./user-profiles-internal.js";
export { hasMultipleSessionSharingIdentities, listProfiles } from "./user-profile-list.js";
type UserProfile = {
id: string;
displayName: string | null;
@@ -40,16 +53,10 @@ type UserProfile = {
type UserProfileListItem = UserProfile & {
emails: string[];
githubIdentity: UserProfileGitHubIdentity | null;
hasAvatar: boolean;
};
type UserProfileAvatar = {
bytes: Uint8Array;
mime: UserProfileAvatarMime;
sha256: string;
updatedAt: number;
};
type UserProfileDisplay = {
id: string;
displayName: string | null;
@@ -61,42 +68,9 @@ type UserProfileAvatarError =
| { code: "avatar_too_large"; maxBytes: number }
| { code: "unsupported_avatar_mime"; mime: string };
export function formatUserProfileAvatarEtag(sha256: string, mime: UserProfileAvatarMime): string {
return `"${sha256}-${mime.slice("image/".length)}"`;
}
export { UserProfileGitHubIdentityConflictError };
export { UserProfileNotFoundError };
export class UserProfileNotFoundError extends Error {
constructor(profileId: string) {
super(`user profile not found: ${profileId}`);
this.name = "UserProfileNotFoundError";
}
}
export type UserProfilesDatabase = {
user_profiles: {
id: string;
display_name: string | null;
avatar: Uint8Array | null;
avatar_mime: string | null;
avatar_sha256: string | null;
merged_into: string | null;
created_at: number;
updated_at: number;
};
user_profile_emails: {
email: string;
profile_id: string;
created_at: number;
};
user_profile_identities: {
provider: string;
subject: string;
profile_id: string;
created_at: number;
};
};
type UserProfileRow = UserProfilesDatabase["user_profiles"];
type UserProfileListRow = Pick<
UserProfileRow,
"id" | "display_name" | "avatar_mime" | "merged_into" | "created_at" | "updated_at"
@@ -104,30 +78,8 @@ type UserProfileListRow = Pick<
has_avatar: unknown;
};
const ensuredDatabases = new WeakSet<DatabaseSync>();
const MAX_USER_PROFILE_DISPLAY_NAME_LENGTH = 256;
function profileDb(db: DatabaseSync) {
return getNodeSqliteKysely<UserProfilesDatabase>(db);
}
export function ensureUserProfilesSchema(options: OpenClawStateDatabaseOptions): void {
const database = openOpenClawStateDatabase(options);
if (ensuredDatabases.has(database.db)) {
return;
}
runOpenClawStateWriteTransaction(
({ db }) => {
db.exec(USER_PROFILES_SCHEMA_SQL);
},
options,
{ operationLabel: "user-profiles.schema.ensure" },
);
// Mark ensured only after the transaction commits; a rolled-back ensure must
// retry the DDL on the next call instead of failing "no such table" forever.
ensuredDatabases.add(database.db);
}
function normalizeEmail(email: string): string {
const normalized = email.trim().toLowerCase();
if (!normalized) {
@@ -141,32 +93,31 @@ function normalizeInitialDisplayName(name: string | undefined): string | null {
return normalized ? normalized.slice(0, MAX_USER_PROFILE_DISPLAY_NAME_LENGTH) : null;
}
function toAvatarMime(value: string | null): UserProfileAvatarMime | null {
return USER_PROFILE_AVATAR_MIME_TYPES.includes(value as UserProfileAvatarMime)
? (value as UserProfileAvatarMime)
: null;
}
function toUserProfile(row: UserProfileRow): UserProfile {
return {
id: row.id,
displayName: row.display_name,
avatarMime: toAvatarMime(row.avatar_mime),
avatarMime: normalizeUserProfileAvatarMime(row.avatar_mime),
mergedInto: row.merged_into,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function toUserProfileListItem(row: UserProfileListRow, emails: string[]): UserProfileListItem {
function toUserProfileListItem(
row: UserProfileListRow,
emails: string[],
githubIdentity: UserProfileGitHubIdentity | null,
): UserProfileListItem {
return {
id: row.id,
displayName: row.display_name,
avatarMime: toAvatarMime(row.avatar_mime),
avatarMime: normalizeUserProfileAvatarMime(row.avatar_mime),
mergedInto: row.merged_into,
createdAt: row.created_at,
updatedAt: row.updated_at,
emails,
githubIdentity,
hasAvatar: row.has_avatar === 1,
};
}
@@ -176,7 +127,7 @@ function hasAvatarColumn() {
}
function selectUserProfileListItemById(db: DatabaseSync, profileId: string): UserProfileListItem {
const kysely = profileDb(db);
const kysely = userProfilesDb(db);
const profile = executeSqliteQueryTakeFirstSync(
db,
kysely
@@ -206,37 +157,10 @@ function selectUserProfileListItemById(db: DatabaseSync, profileId: string): Use
return toUserProfileListItem(
profile,
emails.map((alias) => alias.email),
selectUserProfileGitHubIdentities(db, [profileId]).get(profileId) ?? null,
);
}
function selectProfileById(db: DatabaseSync, profileId: string): UserProfileRow | undefined {
return executeSqliteQueryTakeFirstSync(
db,
profileDb(db).selectFrom("user_profiles").selectAll().where("id", "=", profileId),
);
}
function selectResolvedProfileById(
db: DatabaseSync,
profileId: string,
): UserProfileRow | undefined {
const profile = selectProfileById(db, profileId);
if (!profile?.merged_into) {
return profile;
}
// Every merge re-points aliases and tombstones targeting its source, so this
// one hop preserves durable references while the stored chain stays depth one.
return selectProfileById(db, profile.merged_into) ?? profile;
}
function requireResolvedProfileById(db: DatabaseSync, profileId: string): UserProfileRow {
const profile = selectResolvedProfileById(db, profileId);
if (!profile) {
throw new UserProfileNotFoundError(profileId);
}
return profile;
}
/** Resolves a durable profile reference to its current one-hop merge head. */
export function resolveUserProfileId(
profileId: string,
@@ -244,7 +168,7 @@ export function resolveUserProfileId(
): string | undefined {
ensureUserProfilesSchema(options);
const { db } = openOpenClawStateDatabase(options);
return selectResolvedProfileById(db, profileId)?.id;
return selectResolvedUserProfileById(db, profileId)?.id;
}
/** Reads a profile's protocol-facing representation through its merge head. */
@@ -254,7 +178,7 @@ export function getUserProfileListItem(
): UserProfileListItem {
ensureUserProfilesSchema(options);
const { db } = openOpenClawStateDatabase(options);
return selectUserProfileListItemById(db, requireResolvedProfileById(db, profileId).id);
return selectUserProfileListItemById(db, requireResolvedUserProfileById(db, profileId).id);
}
/** Reads merge-aware display data without exposing avatar content through list/RPC shapes. */
@@ -264,8 +188,8 @@ export function getUserProfileDisplay(
): UserProfileDisplay {
ensureUserProfilesSchema(options);
const { db } = openOpenClawStateDatabase(options);
const profile = requireResolvedProfileById(db, profileId);
const avatarMime = toAvatarMime(profile.avatar_mime);
const profile = requireResolvedUserProfileById(db, profileId);
const avatarMime = normalizeUserProfileAvatarMime(profile.avatar_mime);
const avatarRevision =
profile.avatar_sha256 && avatarMime
? `${profile.avatar_sha256}-${avatarMime.slice("image/".length)}`
@@ -295,7 +219,7 @@ function ensureProfileForEmailWithInitialName(
ensureUserProfilesSchema(options);
return runOpenClawStateWriteTransaction(
({ db }) => {
const kysely = profileDb(db);
const kysely = userProfilesDb(db);
const existingAlias = executeSqliteQueryTakeFirstSync(
db,
kysely
@@ -304,7 +228,7 @@ function ensureProfileForEmailWithInitialName(
.where("email", "=", normalizedEmail),
);
if (existingAlias) {
return toUserProfile(requireResolvedProfileById(db, existingAlias.profile_id));
return toUserProfile(requireResolvedUserProfileById(db, existingAlias.profile_id));
}
const row: UserProfileRow = {
id: profileId,
@@ -351,7 +275,7 @@ function ensureProfileForProviderIdentity(params: {
ensureUserProfilesSchema(params.options);
return runOpenClawStateWriteTransaction(
({ db }) => {
const kysely = profileDb(db);
const kysely = userProfilesDb(db);
const existingIdentity = executeSqliteQueryTakeFirstSync(
db,
kysely
@@ -361,7 +285,7 @@ function ensureProfileForProviderIdentity(params: {
.where("subject", "=", params.subject),
);
if (existingIdentity) {
return toUserProfile(requireResolvedProfileById(db, existingIdentity.profile_id));
return toUserProfile(requireResolvedUserProfileById(db, existingIdentity.profile_id));
}
const row: UserProfileRow = {
id: profileId,
@@ -380,6 +304,7 @@ function ensureProfileForProviderIdentity(params: {
provider: params.provider,
subject: params.subject,
profile_id: profileId,
canonical_login: null,
created_at: now,
}),
);
@@ -397,18 +322,18 @@ function adoptDisplayNameIfEmpty(
): UserProfile {
if (!displayName) {
const { db } = openOpenClawStateDatabase(options);
return toUserProfile(requireResolvedProfileById(db, profileId));
return toUserProfile(requireResolvedUserProfileById(db, profileId));
}
const now = Date.now();
return runOpenClawStateWriteTransaction(
({ db }) => {
const profile = requireResolvedProfileById(db, profileId);
const profile = requireResolvedUserProfileById(db, profileId);
if (profile.display_name !== null) {
return toUserProfile(profile);
}
executeSqliteQuerySync(
db,
profileDb(db)
userProfilesDb(db)
.updateTable("user_profiles")
.set({ display_name: displayName, updated_at: now })
.where("id", "=", profile.id),
@@ -427,25 +352,25 @@ async function adoptAvatarIfEmpty(params: {
fetchOptions: TailscaleAvatarFetchOptions;
}): Promise<UserProfile> {
const { db } = openOpenClawStateDatabase(params.options);
const beforeFetch = requireResolvedProfileById(db, params.profileId);
const beforeFetch = requireResolvedUserProfileById(db, params.profileId);
if (beforeFetch.avatar !== null || !params.profilePic) {
return toUserProfile(beforeFetch);
}
const avatar = await fetchTailscaleAvatar(params.profilePic, params.fetchOptions);
if (!avatar) {
return toUserProfile(requireResolvedProfileById(db, params.profileId));
return toUserProfile(requireResolvedUserProfileById(db, params.profileId));
}
const now = Date.now();
return runOpenClawStateWriteTransaction(
({ db: transactionDb }) => {
const profile = requireResolvedProfileById(transactionDb, params.profileId);
const profile = requireResolvedUserProfileById(transactionDb, params.profileId);
if (profile.avatar !== null) {
return toUserProfile(profile);
}
const sha256 = createHash("sha256").update(avatar.bytes).digest("hex");
executeSqliteQuerySync(
transactionDb,
profileDb(transactionDb)
userProfilesDb(transactionDb)
.updateTable("user_profiles")
.set({
avatar: avatar.bytes,
@@ -516,8 +441,8 @@ export function linkEmail(
ensureUserProfilesSchema(options);
return runOpenClawStateWriteTransaction(
({ db }) => {
const kysely = profileDb(db);
const target = requireResolvedProfileById(db, targetProfileId);
const kysely = userProfilesDb(db);
const target = requireResolvedUserProfileById(db, targetProfileId);
const existingAlias = executeSqliteQueryTakeFirstSync(
db,
kysely
@@ -575,6 +500,14 @@ export function linkEmail(
for (const sourceProfileId of mergeSourceIds) {
mergeUserPreferences(db, sourceProfileId, target.id);
}
mergeUserProfileGitHubIdentity(db, mergeSourceIds, target.id);
executeSqliteQuerySync(
db,
kysely
.updateTable("user_profile_identities")
.set({ profile_id: target.id })
.where("profile_id", "in", mergeSourceIds),
);
executeSqliteQuerySync(
db,
kysely
@@ -614,10 +547,10 @@ export function setDisplayName(
ensureUserProfilesSchema(options);
return runOpenClawStateWriteTransaction(
({ db }) => {
const profile = requireResolvedProfileById(db, profileId);
const profile = requireResolvedUserProfileById(db, profileId);
executeSqliteQuerySync(
db,
profileDb(db)
userProfilesDb(db)
.updateTable("user_profiles")
.set({ display_name: name, updated_at: now })
.where("id", "=", profile.id),
@@ -629,6 +562,23 @@ export function setDisplayName(
);
}
export function setGitHubIdentity(
profileId: string,
identity: { accountId: number; login: string },
options: OpenClawStateDatabaseOptions = {},
): UserProfileListItem {
const canonicalProfileId = setUserProfileGitHubIdentity(profileId, identity, options);
return selectUserProfileListItemById(openOpenClawStateDatabase(options).db, canonicalProfileId);
}
export function clearGitHubIdentity(
profileId: string,
options: OpenClawStateDatabaseOptions = {},
): UserProfileListItem {
const canonicalProfileId = clearUserProfileGitHubIdentity(profileId, options);
return selectUserProfileListItemById(openOpenClawStateDatabase(options).db, canonicalProfileId);
}
/** Stores a bounded, allowlisted avatar without ever leaving the write transaction async. */
export function setAvatar(
profileId: string,
@@ -646,11 +596,11 @@ export function setAvatar(
ensureUserProfilesSchema(options);
const value = runOpenClawStateWriteTransaction(
({ db }) => {
const profile = requireResolvedProfileById(db, profileId);
const profile = requireResolvedUserProfileById(db, profileId);
const sha256 = createHash("sha256").update(bytes).digest("hex");
executeSqliteQuerySync(
db,
profileDb(db)
userProfilesDb(db)
.updateTable("user_profiles")
.set({ avatar: bytes, avatar_mime: mime, avatar_sha256: sha256, updated_at: now })
.where("id", "=", profile.id),
@@ -662,70 +612,3 @@ export function setAvatar(
);
return ok(value);
}
export function getProfileAvatar(
profileId: string,
options: OpenClawStateDatabaseOptions = {},
): UserProfileAvatar | undefined {
ensureUserProfilesSchema(options);
const { db } = openOpenClawStateDatabase(options);
const profile = selectResolvedProfileById(db, profileId);
if (!profile?.avatar || !profile.avatar_mime || !profile.avatar_sha256) {
return undefined;
}
const mime = toAvatarMime(profile.avatar_mime);
return mime
? { bytes: profile.avatar, mime, sha256: profile.avatar_sha256, updatedAt: profile.updated_at }
: undefined;
}
export function listProfiles(options: OpenClawStateDatabaseOptions = {}): UserProfileListItem[] {
ensureUserProfilesSchema(options);
const database = openOpenClawStateDatabase(options);
return runSqliteDeferredTransactionSync(
database.db,
() => {
const kysely = profileDb(database.db);
const profiles = executeSqliteQuerySync(
database.db,
kysely
.selectFrom("user_profiles")
.select([
"id",
"display_name",
"avatar_mime",
"merged_into",
"created_at",
"updated_at",
hasAvatarColumn(),
])
.orderBy("created_at", "asc")
.orderBy("id", "asc"),
).rows;
const emails = executeSqliteQuerySync(
database.db,
kysely
.selectFrom("user_profile_emails")
.select(["profile_id", "email"])
.orderBy("email", "asc"),
).rows;
const emailsByProfile = new Map<string, string[]>();
for (const email of emails) {
const list = emailsByProfile.get(email.profile_id) ?? [];
list.push(email.email);
emailsByProfile.set(email.profile_id, list);
}
return profiles.map((profile) =>
toUserProfileListItem(profile, emailsByProfile.get(profile.id) ?? []),
);
},
{ databaseLabel: database.path, operationLabel: "user-profiles.list" },
);
}
/** True when session-sharing policy can distinguish at least two durable people. */
export function hasMultipleSessionSharingIdentities(
options: OpenClawStateDatabaseOptions = {},
): boolean {
return listProfiles(options).filter((profile) => !profile.mergedInto).length >= 2;
}
+6
View File
@@ -0,0 +1,6 @@
const GITHUB_LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u;
export function normalizeGitHubLogin(value: string): string | undefined {
const login = value.trim();
return GITHUB_LOGIN_PATTERN.test(login) ? login : undefined;
}
+84
View File
@@ -41,8 +41,18 @@ const testProfile = {
createdAt: 1,
updatedAt: 2,
emails: ["test@example.com"],
githubIdentity: null,
hasAvatar: false,
};
const githubAvatarUrl = "https://avatars.githubusercontent.com/u/583231?v=4";
const linkedGitHubProfile = {
...testProfile,
githubIdentity: {
login: "octocat",
profileUrl: "https://github.com/octocat",
avatarUrl: githubAvatarUrl,
},
};
const testPresenceUsers = [
{
self: true,
@@ -90,6 +100,80 @@ suite.define(() => {
});
});
it("links and disconnects a public GitHub identity", async () => {
if (captureUiProof) {
await mkdir(proofDir, { recursive: true });
}
await suite.withPage(
{
...(captureUiProof
? { recordVideo: { dir: proofDir, size: { width: 1280, height: 800 } } }
: {}),
viewport: { width: 1280, height: 800 },
},
async ({ page }) => {
const avatarRequests: string[] = [];
await page.route(githubAvatarUrl, async (route) => {
avatarRequests.push(route.request().url());
await route.fulfill({
body: `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#24292f"/><circle cx="32" cy="27" r="14" fill="white"/><path d="M12 62c2-15 10-23 20-23s18 8 20 23" fill="white"/></svg>`,
contentType: "image/svg+xml",
status: 200,
});
});
const gateway = await openProfilePage(page, {
"users.setGitHubIdentity": { profile: linkedGitHubProfile },
"users.clearGitHubIdentity": { profile: testProfile },
});
const githubRow = page
.locator("#settings-profile-identity .settings-row")
.filter({ has: page.locator(".settings-row__title", { hasText: "GitHub" }) });
await expect(githubRow).toContainText(
"Linking opts you into public GitHub co-author credit when you participate in agent sessions that create commits.",
);
await expect(githubRow).toContainText(
"Commit credit uses GitHub's public noreply address, never a private email.",
);
await expect(githubRow).toContainText("Link only an account you control.");
await expect(githubRow.locator(".settings-account")).toHaveCount(0);
await screenshot(page, "08-github-identity-unlinked.png");
const username = githubRow.getByRole("textbox", { name: "GitHub username" });
await username.fill("octocat");
await githubRow.getByRole("button", { name: "Link GitHub" }).click();
const linkRequest = await gateway.waitForRequest("users.setGitHubIdentity");
expect(linkRequest.params).toEqual({ username: "octocat" });
const account = githubRow.getByRole("link", { name: "@octocat" });
await expect(account).toBeVisible();
await expect(account).toHaveAttribute("href", "https://github.com/octocat");
await expect(account).toHaveAttribute("target", "_blank");
await account.focus();
await expect(account).toBeFocused();
const avatar = account.locator("img");
await expect(avatar).toBeVisible();
await expect(avatar).toHaveAttribute("src", githubAvatarUrl);
await expect
.poll(() => avatar.evaluate((image) => (image as HTMLImageElement).naturalWidth))
.toBe(64);
expect(avatarRequests).toEqual([githubAvatarUrl]);
await expect(username).toHaveValue("octocat");
const unchangedSubmit = githubRow.getByRole("button", { name: "Change" });
await expect(unchangedSubmit).toBeDisabled();
expect(await gateway.getRequests("users.setGitHubIdentity")).toHaveLength(1);
await screenshot(page, "09-github-identity-linked.png");
await githubRow.getByRole("button", { name: "Disconnect" }).click();
const clearRequest = await gateway.waitForRequest("users.clearGitHubIdentity");
expect(clearRequest.params).toEqual({});
await expect(githubRow.locator(".settings-account")).toHaveCount(0);
await expect(username).toHaveValue("");
await expect(githubRow.getByRole("button", { name: "Link GitHub" })).toBeVisible();
},
);
});
it("renders the protected assistant avatar through an authenticated blob fetch", async () => {
await suite.withPage(
{
+11
View File
@@ -3417,6 +3417,17 @@ export const en: TranslationMap = {
displayNameDescription: "Shown to other people using this gateway.",
linkedEmails: "Linked emails",
linkedEmailsDescription: "Email addresses connected to this profile.",
github: "GitHub",
githubDescription:
"Linking opts you into public GitHub co-author credit when you participate in agent sessions that create commits.",
githubUsername: "GitHub username",
githubPlaceholder: "octocat",
githubLink: "Link GitHub",
githubLinking: "Linking…",
githubChange: "Change",
githubDisconnect: "Disconnect",
githubPrivacy: "Commit credit uses GitHub's public noreply address, never a private email.",
githubOwnership: "Link only an account you control.",
avatarErrors: {
invalid: "That image could not be processed.",
sourceTooLarge: "Choose an image that is 10 MB or smaller.",
+81 -1
View File
@@ -16,6 +16,7 @@ const PROFILE: UserProfile = {
createdAt: 1,
updatedAt: 2,
emails: ["ada@example.test", "ada@work.test"],
githubIdentity: null,
hasAvatar: true,
};
@@ -24,11 +25,15 @@ function createProps(overrides: Partial<IdentitySectionProps> = {}): IdentitySec
profile: PROFILE,
avatarUrl: "/api/users/profile-1/avatar?v=2",
displayName: "Ada Lovelace",
githubUsername: "",
busy: null,
error: null,
onDisplayNameInput: vi.fn(),
onSaveDisplayName: vi.fn(),
onAvatarSelect: vi.fn(),
onGitHubUsernameInput: vi.fn(),
onSaveGitHubIdentity: vi.fn(),
onClearGitHubIdentity: vi.fn(),
...overrides,
};
}
@@ -58,7 +63,7 @@ describe("renderIdentitySection", () => {
[...container.querySelectorAll(".settings-row__title")].map((node) =>
node.textContent?.trim(),
),
).toEqual(["Avatar", "Display name", "Linked emails"]);
).toEqual(["Avatar", "Display name", "Linked emails", "GitHub"]);
expect(container.textContent).toContain("ada@example.test, ada@work.test");
});
@@ -125,6 +130,81 @@ describe("renderIdentitySection", () => {
expect(onAvatarSelect).toHaveBeenCalledWith(file);
});
it("links, changes, and disconnects the public GitHub identity", () => {
const onGitHubUsernameInput = vi.fn();
const onSaveGitHubIdentity = vi.fn();
const onClearGitHubIdentity = vi.fn();
const container = document.createElement("div");
render(
renderIdentitySection(
createProps({
githubUsername: "octocat",
profile: {
...PROFILE,
githubIdentity: {
login: "octocat",
profileUrl: "https://github.com/octocat",
avatarUrl: "https://avatars.githubusercontent.com/u/583231?v=4",
},
},
onGitHubUsernameInput,
onSaveGitHubIdentity,
onClearGitHubIdentity,
}),
),
container,
);
const account = container.querySelector<HTMLAnchorElement>(".settings-account");
expect(account?.href).toBe("https://github.com/octocat");
expect(account?.target).toBe("_blank");
expect(account?.rel).toContain("noopener");
expect(account?.querySelector("img")?.src).toBe(
"https://avatars.githubusercontent.com/u/583231?v=4",
);
const input = container.querySelector<HTMLInputElement>(".identity-github-form input");
input!.value = "octo-renamed";
input!.dispatchEvent(new Event("input", { bubbles: true }));
container
.querySelector<HTMLFormElement>(".identity-github-form")
?.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
[...container.querySelectorAll<HTMLButtonElement>("button")]
.find((button) => button.textContent?.trim() === "Disconnect")
?.click();
expect(onGitHubUsernameInput).toHaveBeenCalledWith("octo-renamed");
expect(onSaveGitHubIdentity).toHaveBeenCalledOnce();
expect(onClearGitHubIdentity).toHaveBeenCalledOnce();
expect(container.textContent).toContain("public GitHub co-author credit");
expect(container.textContent).toContain("never a private email");
expect(container.textContent).toContain("account you control");
});
it("disables relinking an unchanged canonical GitHub login", () => {
const container = document.createElement("div");
render(
renderIdentitySection(
createProps({
githubUsername: " OctoCat ",
profile: {
...PROFILE,
githubIdentity: {
login: "octocat",
profileUrl: "https://github.com/octocat",
avatarUrl: "https://avatars.githubusercontent.com/u/583231?v=4",
},
},
}),
),
container,
);
expect(
container.querySelector<HTMLButtonElement>('.identity-github-form button[type="submit"]')
?.disabled,
).toBe(true);
});
it("reports mutation errors without inventing another settings surface", () => {
const container = document.createElement("div");
render(renderIdentitySection(createProps({ error: "Save failed" })), container);
+79 -1
View File
@@ -7,6 +7,7 @@ import {
} from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import "../../components/viewer-facepile.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../../lib/external-link.ts";
import type { PresenceViewer } from "../../lib/presence-users.ts";
import { PROFILE_SETTINGS_TARGET_IDS } from "../config/settings-targets.ts";
@@ -14,11 +15,15 @@ type IdentitySectionProps = {
profile: UserProfile;
avatarUrl: string | null;
displayName: string;
busy: "display-name" | "avatar" | "loading" | null;
githubUsername: string;
busy: "display-name" | "avatar" | "github" | "loading" | null;
error: string | null;
onDisplayNameInput: (value: string) => void;
onSaveDisplayName: () => void;
onAvatarSelect: (file: File) => void;
onGitHubUsernameInput: (value: string) => void;
onSaveGitHubIdentity: () => void;
onClearGitHubIdentity: () => void;
};
function avatarViewer(profile: UserProfile, avatarUrl: string | null): PresenceViewer {
@@ -35,6 +40,9 @@ export function renderIdentitySection(props: IdentitySectionProps) {
const savedName = props.profile.displayName ?? "";
const nameChanged = props.displayName.trim() !== savedName;
const emails = props.profile.emails.join(", ");
const githubIdentity = props.profile.githubIdentity;
const githubLoginChanged =
props.githubUsername.trim().toLowerCase() !== (githubIdentity?.login.toLowerCase() ?? "");
return html`<div id=${PROFILE_SETTINGS_TARGET_IDS.identity}>
${renderSettingsSection(
{
@@ -109,6 +117,76 @@ export function renderIdentitySection(props: IdentitySectionProps) {
description: t("profilePage.identity.linkedEmailsDescription"),
control: emails ? renderSettingsValue(emails) : nothing,
})}
${renderSettingsRow({
title: t("profilePage.identity.github"),
description: t("profilePage.identity.githubDescription"),
control: html`
<div class="identity-github-control">
${githubIdentity
? html`<a
class="settings-account"
href=${githubIdentity.profileUrl}
target=${EXTERNAL_LINK_TARGET}
rel=${buildExternalLinkRel()}
>
<img class="settings-account__avatar" src=${githubIdentity.avatarUrl} alt="" />
<span class="settings-row__value settings-row__value--mono"
>@${githubIdentity.login}</span
>
</a>`
: nothing}
<form
class="identity-github-form"
@submit=${(event: SubmitEvent) => {
event.preventDefault();
props.onSaveGitHubIdentity();
}}
>
<input
class="settings-input"
type="text"
maxlength="39"
autocomplete="off"
spellcheck="false"
aria-label=${t("profilePage.identity.githubUsername")}
placeholder=${t("profilePage.identity.githubPlaceholder")}
.value=${props.githubUsername}
?disabled=${props.busy !== null}
@input=${(event: Event) => {
if (event.currentTarget instanceof HTMLInputElement) {
props.onGitHubUsernameInput(event.currentTarget.value);
}
}}
/>
<button
type="submit"
class="btn btn--sm"
?disabled=${props.busy !== null ||
!props.githubUsername.trim() ||
!githubLoginChanged}
>
${props.busy === "github"
? t("profilePage.identity.githubLinking")
: githubIdentity
? t("profilePage.identity.githubChange")
: t("profilePage.identity.githubLink")}
</button>
${githubIdentity
? html`<button
type="button"
class="btn btn--sm"
?disabled=${props.busy !== null}
@click=${props.onClearGitHubIdentity}
>
${t("profilePage.identity.githubDisconnect")}
</button>`
: nothing}
</form>
<span class="settings-row__desc">${t("profilePage.identity.githubPrivacy")}</span>
<span class="settings-row__desc">${t("profilePage.identity.githubOwnership")}</span>
</div>
`,
})}
${props.error
? html`<div class="settings-row identity-error" role="alert">
<span class="settings-row__desc">${props.error}</span>
@@ -200,6 +200,7 @@ it("renders identity before a Usage statistics link without requesting usage dat
createdAt: 1,
updatedAt: 2,
emails: ["ada@example.test"],
githubIdentity: null,
hasAvatar: false,
};
const request = vi.fn(async (method: string) => {
@@ -378,6 +379,7 @@ it("retries the identity bootstrap when users.self returns no profile", async ()
createdAt: 1,
updatedAt: 2,
emails: ["ada@example.test"],
githubIdentity: null,
hasAvatar: false,
};
let identityRequests = 0;
@@ -424,6 +426,7 @@ it("keeps identity refresh single-flight and allows retry after settlement", asy
createdAt: 1,
updatedAt: 2,
emails: ["ada@example.test"],
githubIdentity: null,
hasAvatar: false,
};
let rejectIdentity: ((reason: Error) => void) | undefined;
@@ -485,6 +488,7 @@ it("replaces an in-flight identity request after a same-client reconnect", async
createdAt: 1,
updatedAt: 2,
emails: ["ada@example.test"],
githubIdentity: null,
hasAvatar: false,
};
const freshProfile = { ...staleProfile, displayName: "Fresh identity", updatedAt: 3 };
@@ -546,6 +550,7 @@ it("bootstraps and refreshes the connected user's profile through users.self", a
createdAt: 1,
updatedAt: 2,
emails: ["ada@example.test", "ada@work.test"],
githubIdentity: null,
hasAvatar: false,
};
let omitNextProfile = false;
+79 -1
View File
@@ -3,9 +3,11 @@ import { html, nothing } from "lit";
import { state } from "lit/decorators.js";
import type {
UserProfile,
UsersClearGitHubIdentityResult,
UsersSelfResult,
UsersSetAvatarResult,
UsersSetDisplayNameResult,
UsersSetGitHubIdentityResult,
} from "../../../../packages/gateway-protocol/src/index.ts";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
@@ -50,8 +52,9 @@ export class ProfilePage extends OpenClawLightDomElement {
@state() private selfUser: AuthenticatedUser | null = null;
@state() private ownProfile: UserProfile | null = null;
@state() private displayName = "";
@state() private githubUsername = "";
@state() private identityLoading = false;
@state() private identityBusy: "display-name" | "avatar" | null = null;
@state() private identityBusy: "display-name" | "avatar" | "github" | null = null;
@state() private identityError: string | null = null;
@state() private failedHeroAvatarUrl: string | null = null;
@@ -128,6 +131,7 @@ export class ProfilePage extends OpenClawLightDomElement {
this.identityRequestId += 1;
this.ownProfile = null;
this.displayName = "";
this.githubUsername = "";
this.identityLoading = false;
this.identityBusy = null;
this.identityError = null;
@@ -155,8 +159,12 @@ export class ProfilePage extends OpenClawLightDomElement {
const requestId = ++this.identityRequestId;
const currentProfile = this.ownProfile;
const displayNameDraft = this.displayName;
const githubUsernameDraft = this.githubUsername;
const hasUnsavedDisplayName =
currentProfile !== null && displayNameDraft.trim() !== (currentProfile.displayName ?? "");
const hasUnsavedGitHubUsername =
currentProfile !== null &&
githubUsernameDraft.trim() !== (currentProfile.githubIdentity?.login ?? "");
this.identityLoading = true;
this.identityError = null;
try {
@@ -170,6 +178,9 @@ export class ProfilePage extends OpenClawLightDomElement {
}
this.ownProfile = profile;
this.displayName = hasUnsavedDisplayName ? displayNameDraft : (profile.displayName ?? "");
this.githubUsername = hasUnsavedGitHubUsername
? githubUsernameDraft
: (profile.githubIdentity?.login ?? "");
} catch (error) {
if (requestId === this.identityRequestId) {
this.identityError = toIdentityErrorMessage(error);
@@ -184,6 +195,7 @@ export class ProfilePage extends OpenClawLightDomElement {
private applyOwnProfile(profile: UserProfile) {
this.ownProfile = profile;
this.displayName = profile.displayName ?? "";
this.githubUsername = profile.githubIdentity?.login ?? "";
}
private async saveDisplayName() {
@@ -287,6 +299,66 @@ export class ProfilePage extends OpenClawLightDomElement {
}
}
private async saveGitHubIdentity() {
const profile = this.ownProfile;
const username = this.githubUsername.trim();
if (
!profile ||
!username ||
username.toLowerCase() === profile.githubIdentity?.login.toLowerCase()
) {
return;
}
await this.runGitHubIdentityMutation(
async (client) =>
(
await client.request<UsersSetGitHubIdentityResult>("users.setGitHubIdentity", {
username,
})
).profile,
);
}
private async clearGitHubIdentity() {
await this.runGitHubIdentityMutation(
async (client) =>
(await client.request<UsersClearGitHubIdentityResult>("users.clearGitHubIdentity", {}))
.profile,
);
}
private async runGitHubIdentityMutation(
mutate: (client: GatewayBrowserClient) => Promise<UserProfile>,
) {
const client = this.client;
const profile = this.ownProfile;
if (!client || !profile || this.identityBusy || this.identityLoading) {
return;
}
this.identityBusy = "github";
this.identityError = null;
const identityRequestId = this.identityRequestId;
const displayNameDraft = this.displayName;
const hasUnsavedDisplayName = displayNameDraft.trim() !== (profile.displayName ?? "");
try {
const nextProfile = await mutate(client);
if (client !== this.client || identityRequestId !== this.identityRequestId) {
return;
}
this.ownProfile = nextProfile;
this.displayName = hasUnsavedDisplayName ? displayNameDraft : (nextProfile.displayName ?? "");
this.githubUsername = nextProfile.githubIdentity?.login ?? "";
} catch (error) {
if (client === this.client && identityRequestId === this.identityRequestId) {
this.identityError = toIdentityErrorMessage(error);
}
} finally {
if (identityRequestId === this.identityRequestId && this.identityBusy === "github") {
this.identityBusy = null;
}
}
}
private renderIdentity() {
if (!this.selfUser) {
return nothing;
@@ -325,6 +397,7 @@ export class ProfilePage extends OpenClawLightDomElement {
profile: this.ownProfile,
avatarUrl,
displayName: this.displayName,
githubUsername: this.githubUsername,
busy: this.identityLoading ? "loading" : this.identityBusy,
error: this.identityError,
onDisplayNameInput: (value) => {
@@ -332,6 +405,11 @@ export class ProfilePage extends OpenClawLightDomElement {
},
onSaveDisplayName: () => void this.saveDisplayName(),
onAvatarSelect: (file) => void this.saveAvatar(file),
onGitHubUsernameInput: (value) => {
this.githubUsername = value;
},
onSaveGitHubIdentity: () => void this.saveGitHubIdentity(),
onClearGitHubIdentity: () => void this.clearGitHubIdentity(),
});
}
+15
View File
@@ -75,3 +75,18 @@
align-items: center;
gap: var(--space-3);
}
.identity-github-control {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
min-width: 0;
}
.identity-github-form {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}