A session g1t starts on Claude Managed Agents acts as g1t for the person who started it: on flagon-io/demo #1 the session closed the issue and the timeline said "syntaqx closed this as completed", because its token was a plain personal access token for @syntaqx (create_access_token, scoped to the repository), so every comment, close, review and pull request it made read as theirs. It now holds a run credential of a new kind, session (credentials::RunCredentialKind::Session, made by identity's create_run_credential, up to 12 hours, revoked when the session ends as before): it resolves to g1t on behalf of the person, may use every API and MCP operation credentials::SESSION_SCOPES allow (the scopes the old token had) and never one in NEVER, in its repository only, with the person's role there capped at Write, and pushes with git to that repository (any branch, so a new repository's first version still goes to its default branch) and to the working copies of its pull requests (decide_git_at, resolved by repos from the fork's source). Past the API and git it is the person with acting kept (as_person), so the session, its claims, its pull requests' forks and its pushes stay theirs and their role decides, while what it writes is recorded as g1t's for them: issues and pull requests it opens are g1t's, requested by them (work::authorship), and its comments, reviews and the events it notes (close, reopen, labels, assignees, milestones, blocked-by, ready, draft) are written as g1t with acting_for the person (work rows::By, into the acting_for columns of migration 0033), shown as "g1t ... on behalf of @syntaqx" on the issue and pull request pages; the person can still edit them, and a session's approval counts only where agents' approvals do. The system prompt says the token is g1t's for that person. The sessions guide and the identity architecture page say what a session acts as. Deploy order: every Worker that reads a viewer (api, repos, work, packages, events, identity and the rest) before integrations, which is the only one that makes the new kind; no migration. Checked: cargo test -p g1t-contracts (316) -p g1t-work (201) -p g1t-identity (183) -p g1t-integrations (76) -p g1t-repos (363) -p g1t-api (209) -p g1t-packages (112); cargo check --target wasm32-unknown-unknown of g1t-work, g1t-identity, g1t-integrations, g1t-repos, g1t-api, g1t-packages; apps/web tsc -b shows no errors in components/work.tsx (the rest need typegen).
- G1t-Session:
- ses_01m4z1z7nse8wvnxcg0yt8d8dk
15 files+383−850/15 viewed
| 22 | 22 | | Access token | `g1t_…`, personal or a workspace's, with scopes | The API, MCP, git, the CLI | `user_for_access_token` | | |
| 23 | 23 | | OAuth access token | Issued by `POST /oauth/token`, refreshed with `g1r_…` | MCP clients, the phone app, apps you authorise | `user_for_access_token` | | |
| 24 | 24 | | Job token | `G1T_TOKEN` in a workflow job | Actions, for that job alone | `user_for_access_token` | | |
| 25 | − | | Run credential | Made per sandbox run, up to 6 hours | Sandboxes | `user_for_access_token` | | |
| 25 | + | | Run credential | Made per sandbox run, up to 6 hours, or per [session](/guides/sessions/) g1t starts on Claude Managed Agents, up to 12 | Sandboxes; a session's agent, for the API, MCP, git and the CLI | `user_for_access_token` | | |
| 26 | 26 | | git password | Your account's password | git, only without two-factor authentication | `user_for_git_credentials` | | |
| 27 | 27 | ||
| 28 | 28 | The CLI signs in with the device flow of RFC 8628 (`/device/code`, then | |
| ⋯ | |||
| 41 | 41 | | `kind` | `User`, `Workspace`, `Agent` or `System`: what kind of principal this is | | |
| 42 | 42 | | `workspaces` | Each membership: the role, the workspace's base permission, the roles that add to a member, such as billing or security manager, and privileges | | |
| 43 | 43 | | `grants` | Roles on repositories, directly or through teams | | |
| 44 | − | | `acting` | For a job or run token: the narrower scope it acts within | | |
| 44 | + | | `acting` | For a job or run token: the narrower scope it acts within. A session's run credential resolves to g1t acting on behalf of the person who started it; past the API it is that person with `acting` kept, so their role decides and what it writes is recorded as g1t's for them | | |
| 45 | 45 | ||
| 46 | 46 | A service never asks `identity` whether someone may do something. Each | |
| 47 | 47 | calls `can` in `g1t_contracts::access`, the same rules compiled into every | |
| 299 | 299 | g1t's MCP server is attached too, for anything the CLI has no command | |
| 300 | 300 | for. Its sandbox reaches the internet as a CI runner does, and it can | |
| 301 | 301 | search and read the web (web search and web fetch), for a fact the work | |
| 302 | − | needs from outside g1t or to open its own deploy. Both reach g1t with a token made for you | |
| 303 | − | and that repository only, which lasts the session and is revoked when it | |
| 304 | − | ends. The token is kept in a vault on the Anthropic account: the MCP | |
| 302 | + | needs from outside g1t or to open its own deploy. Both reach g1t with one token: g1t | |
| 303 | + | acting on your behalf, in that repository only, which lasts the session | |
| 304 | + | and is revoked when it ends. What the session does on g1t shows as | |
| 305 | + | **g1t, on behalf of @you**: its comments and reviews, what it closes or | |
| 306 | + | labels, and the issues and pull requests it opens (g1t's, requested by | |
| 307 | + | you); the audit log says "g1t on behalf of you". What it may do is your | |
| 308 | + | role in that repository, never more than Write, with what working on an | |
| 309 | + | issue needs (`repo:read`, `code:write`, `issues:write`, | |
| 310 | + | `pull_requests:write`, and reading checks, workflows, deployments and | |
| 311 | + | observability): never settings, members, secrets, webhooks or merging. | |
| 312 | + | The session itself, its claims and its commits stay yours, and its | |
| 313 | + | pushes are yours too: to the repository and to its pull requests' | |
| 314 | + | working copies, under the repository's rules. An approval it gives is | |
| 315 | + | g1t's, so it counts toward required approvals only where agents' | |
| 316 | + | approvals do. The token is kept in a vault on the Anthropic account: the MCP | |
| 305 | 317 | server's credential, and `G1T_TOKEN`, which the sandbox holds only as a | |
| 306 | 318 | placeholder that is swapped for the token on its way to g1t. It is never | |
| 307 | 319 | in the prompt, and the model never sees it. The session has g1t's skills |
| 262 | 262 | author, | |
| 263 | 263 | bot = false, | |
| 264 | 264 | agent, | |
| 265 | + | actingFor = agent?.actingFor, | |
| 265 | 266 | action, | |
| 266 | 267 | at, | |
| 267 | 268 | edited, | |
| ⋯ | |||
| 279 | 280 | * of `author`. | |
| 280 | 281 | */ | |
| 281 | 282 | agent?: { ref: AgentRef; actingFor?: string | null }; | |
| 283 | + | /** Whom g1t acted for, when a session g1t started for someone wrote it. */ | |
| 284 | + | actingFor?: string | null; | |
| 282 | 285 | /** What they did, after their name: "commented", "opened this". */ | |
| 283 | 286 | action: ReactNode; | |
| 284 | 287 | at?: string; | |
| ⋯ | |||
| 318 | 321 | )} | |
| 319 | 322 | {action} | |
| 320 | 323 | {at && <TimeAgo at={at} />} | |
| 321 | − | {agent?.actingFor && ( | |
| 324 | + | {actingFor && ( | |
| 322 | 325 | <span className="text-xs text-faint"> | |
| 323 | 326 | on behalf of{" "} | |
| 324 | − | <PersonLink name={agent.actingFor} className="hover:text-fg hover:underline"> | |
| 325 | − | @{agent.actingFor} | |
| 327 | + | <PersonLink name={actingFor} className="hover:text-fg hover:underline"> | |
| 328 | + | @{actingFor} | |
| 326 | 329 | </PersonLink> | |
| 327 | 330 | </span> | |
| 328 | 331 | )} | |
| ⋯ | |||
| 644 | 647 | key={comment.id} | |
| 645 | 648 | id={comment.id} | |
| 646 | 649 | author={comment.author.username} | |
| 650 | + | bot={comment.author.kind === "agent"} | |
| 647 | 651 | agent={ | |
| 648 | 652 | comment.agent && { | |
| 649 | 653 | ref: comment.agent, | |
| 650 | 654 | actingFor: comment.actingFor?.username, | |
| 651 | 655 | } | |
| 652 | 656 | } | |
| 657 | + | actingFor={comment.actingFor?.username} | |
| 653 | 658 | at={comment.createdAt} | |
| 654 | 659 | action={ | |
| 655 | 660 | <> | |
| 8 | 8 | //! intersection of the two: the run's scope, and what that person may do | |
| 9 | 9 | //! right now. | |
| 10 | 10 | //! | |
| 11 | + | //! A session g1t starts on an agent's harness (Claude Managed Agents) holds | |
| 12 | + | //! one too, of the kind `session`: what it does over the API, MCP and the | |
| 13 | + | //! CLI is recorded as g1t's, for that person, and git takes its pushes as | |
| 14 | + | //! theirs. See [`RunCredentialKind::Session`]. | |
| 15 | + | //! | |
| 11 | 16 | //! g1t's retired hosted agents had run credentials too (`implement`, | |
| 12 | 17 | //! `revise`, `review`, `answer`, `update`, `plan`). Those kinds still parse, | |
| 13 | 18 | //! so stored tokens and the history they wrote read as before, but no | |
| ⋯ | |||
| 50 | 55 | /// A security update: raising one package's version in its lockfiles | |
| 51 | 56 | /// and pushing that to a branch of its own. Not an agent. | |
| 52 | 57 | Bump, | |
| 58 | + | /// A session g1t started on an agent's harness (Claude Managed Agents) | |
| 59 | + | /// for the person who asked, on one issue or pull request. It works | |
| 60 | + | /// through the API, MCP and the CLI with what [`SESSION_SCOPES`] allow, | |
| 61 | + | /// in its repository only, and pushes to that repository and its pull | |
| 62 | + | /// requests' working copies. What it writes (comments, reviews, closes, | |
| 63 | + | /// the issues and pull requests it opens) is g1t's, for that person; | |
| 64 | + | /// what it may do is that person's role there, never beyond | |
| 65 | + | /// [`AGENT_CEILING`]. | |
| 66 | + | Session, | |
| 53 | 67 | } | |
| 54 | 68 | ||
| 55 | 69 | impl RunCredentialKind { | |
| 56 | − | pub const ALL: [RunCredentialKind; 11] = [ | |
| 70 | + | pub const ALL: [RunCredentialKind; 12] = [ | |
| 57 | 71 | RunCredentialKind::Implement, | |
| 58 | 72 | RunCredentialKind::Revise, | |
| 59 | 73 | RunCredentialKind::Review, | |
| ⋯ | |||
| 65 | 79 | RunCredentialKind::Mergecheck, | |
| 66 | 80 | RunCredentialKind::Deploy, | |
| 67 | 81 | RunCredentialKind::Bump, | |
| 82 | + | RunCredentialKind::Session, | |
| 68 | 83 | ]; | |
| 69 | 84 | ||
| 70 | 85 | pub fn as_str(self) -> &'static str { | |
| ⋯ | |||
| 80 | 95 | RunCredentialKind::Mergecheck => "mergecheck", | |
| 81 | 96 | RunCredentialKind::Deploy => "deploy", | |
| 82 | 97 | RunCredentialKind::Bump => "bump", | |
| 98 | + | RunCredentialKind::Session => "session", | |
| 83 | 99 | } | |
| 84 | 100 | } | |
| 85 | 101 | ||
| ⋯ | |||
| 105 | 121 | pub enum CredentialUse { | |
| 106 | 122 | /// g1t's runner: cloning, fetching and pushing what the run needs. It | |
| 107 | 123 | /// acts as the person downstream, so that what it pushes is theirs, | |
| 108 | − | /// within the run's scope. | |
| 124 | + | /// within the run's scope. A session's is also its agent's token for | |
| 125 | + | /// the API, MCP and the CLI. | |
| 109 | 126 | Runner, | |
| 110 | 127 | /// A retired hosted agent's own tools, over MCP. Kept so stored tokens | |
| 111 | 128 | /// parse; never issued. | |
| ⋯ | |||
| 516 | 533 | READ_OPERATIONS.contains(&operation) | |
| 517 | 534 | } | |
| 518 | 535 | ||
| 536 | + | /// The scopes of a session's credential ([`RunCredentialKind::Session`]): | |
| 537 | + | /// what working on an issue needs, named as an access token's are. | |
| 538 | + | pub const SESSION_SCOPES: [&str; 8] = [ | |
| 539 | + | "repo:read", | |
| 540 | + | "code:write", | |
| 541 | + | "issues:write", | |
| 542 | + | "pull_requests:write", | |
| 543 | + | "checks:read", | |
| 544 | + | "workflows:read", | |
| 545 | + | "deployments:read", | |
| 546 | + | "observability:read", | |
| 547 | + | ]; | |
| 548 | + | ||
| 519 | 549 | /// The API and MCP operations a run of `kind` may use with a credential | |
| 520 | − | /// for `usage`. Git is separate: see [`decide_git`]. None today: every run | |
| 521 | − | /// still issued works through git alone, and a | |
| 550 | + | /// for `usage`. Git is separate: see [`decide_git`]. A session's may use | |
| 551 | + | /// every operation [`SESSION_SCOPES`] allow, and never one in [`NEVER`]; | |
| 552 | + | /// every other run still issued works through git alone, and a | |
| 522 | 553 | /// [retired](RunCredentialKind::retired) kind grants nothing. A stored | |
| 523 | 554 | /// token keeps the operations it was made with until it expires. | |
| 524 | 555 | pub fn operations_for(kind: RunCredentialKind, usage: CredentialUse) -> Vec<&'static str> { | |
| 525 | − | let _ = (kind, usage); | |
| 526 | − | Vec::new() | |
| 556 | + | use crate::scopes::{NO_SCOPE, OPERATIONS, Scope}; | |
| 557 | + | if kind != RunCredentialKind::Session || usage != CredentialUse::Runner { | |
| 558 | + | return Vec::new(); | |
| 559 | + | } | |
| 560 | + | let held: Vec<Scope> = SESSION_SCOPES.iter().filter_map(|scope| Scope::parse(scope)).collect(); | |
| 561 | + | NO_SCOPE | |
| 562 | + | .iter() | |
| 563 | + | .copied() | |
| 564 | + | .chain( | |
| 565 | + | OPERATIONS | |
| 566 | + | .iter() | |
| 567 | + | .filter(|(_, needed)| held.iter().any(|scope| scope.includes(*needed))) | |
| 568 | + | .map(|(name, _)| *name), | |
| 569 | + | ) | |
| 570 | + | .filter(|name| !NEVER.contains(name)) | |
| 571 | + | .collect() | |
| 527 | 572 | } | |
| 528 | 573 | ||
| 529 | 574 | /// What a run's credential may do, in the scope vocabulary that access | |
| ⋯ | |||
| 538 | 583 | .collect(); | |
| 539 | 584 | if usage == CredentialUse::Runner && !kind.retired() { | |
| 540 | 585 | scopes.push(Scope::CodeRead); | |
| 541 | − | if kind == RunCredentialKind::Bump { | |
| 586 | + | if matches!(kind, RunCredentialKind::Bump | RunCredentialKind::Session) { | |
| 542 | 587 | scopes.push(Scope::CodeWrite); | |
| 543 | 588 | } | |
| 544 | 589 | } | |
| ⋯ | |||
| 660 | 705 | /// Whether a run credential may clone or fetch (`write` false), or push to | |
| 661 | 706 | /// (`write` true), the repository at `repo`. | |
| 662 | 707 | pub fn decide_git(scope: &AgentScope, repo: &RepoPath, write: bool) -> Decision { | |
| 708 | + | decide_git_at(scope, repo, None, write) | |
| 709 | + | } | |
| 710 | + | ||
| 711 | + | /// [`decide_git`] for the repository at `repo`, which is a pull request's | |
| 712 | + | /// working copy of `source` when `source` is set. A session's credential | |
| 713 | + | /// reaches the working copies of its own repository's pull requests as it | |
| 714 | + | /// does the repository: the pull requests it opens get theirs after the | |
| 715 | + | /// credential is made. | |
| 716 | + | pub fn decide_git_at(scope: &AgentScope, repo: &RepoPath, source: Option<&RepoPath>, write: bool) -> Decision { | |
| 663 | 717 | let Some(run) = scope | |
| 664 | 718 | .run | |
| 665 | 719 | .as_ref() | |
| ⋯ | |||
| 670 | 724 | "A tools token cannot be used with git.", | |
| 671 | 725 | ); | |
| 672 | 726 | }; | |
| 673 | − | let pushable = run.push.iter().any(|grant| same_repo(&grant.repo, repo)); | |
| 727 | + | let own_copy = run.kind == RunCredentialKind::Session && source.is_some_and(|source| same_repo(source, &scope.repo)); | |
| 728 | + | let pushable = own_copy || run.push.iter().any(|grant| same_repo(&grant.repo, repo)); | |
| 674 | 729 | if write { | |
| 675 | 730 | return if pushable { | |
| 676 | 731 | Decision::allow(format!("{}:push", scope_rule(scope))) | |
| ⋯ | |||
| 795 | 850 | } | |
| 796 | 851 | ||
| 797 | 852 | /// Who a runner's credential acts as downstream: the person, with only the | |
| 798 | − | /// agent's (already intersected) memberships. `None` for anything else. | |
| 853 | + | /// agent's (already intersected) memberships. A session's keeps what it | |
| 854 | + | /// acts as ([`Acting`]), so that what it writes is recorded as g1t's for | |
| 855 | + | /// that person ([`crate::work::authorship`]), while whose it is (its | |
| 856 | + | /// session, its claims) and what may be done stay the person's. `None` | |
| 857 | + | /// for anything else. | |
| 799 | 858 | pub fn as_person(user: &User) -> Option<User> { | |
| 800 | 859 | let acting = user.acting.as_ref()?; | |
| 801 | 860 | if user.kind != PrincipalKind::Agent { | |
| ⋯ | |||
| 813 | 872 | verified: user.verified, | |
| 814 | 873 | workspaces: user.workspaces.clone(), | |
| 815 | 874 | avatar: None, | |
| 816 | − | acting: None, | |
| 875 | + | acting: (run.kind == RunCredentialKind::Session).then(|| acting.clone()), | |
| 817 | 876 | grants: user.grants.clone(), | |
| 818 | 877 | token: None, | |
| 819 | 878 | held: Vec::new(), | |
| ⋯ | |||
| 857 | 916 | let parsed: RunCredentialKind = serde_json::from_value(serde_json::json!(kind.as_str())).unwrap(); | |
| 858 | 917 | assert_eq!(parsed, kind); | |
| 859 | 918 | for usage in [CredentialUse::Runner, CredentialUse::Tools] { | |
| 919 | + | if kind == RunCredentialKind::Session && usage == CredentialUse::Runner { | |
| 920 | + | continue; | |
| 921 | + | } | |
| 860 | 922 | assert!(operations_for(kind, usage).is_empty(), "{kind:?} {usage:?}"); | |
| 861 | 923 | if kind.retired() { | |
| 862 | 924 | assert!(run_scopes(kind, usage).is_empty(), "{kind:?} {usage:?}"); | |
| ⋯ | |||
| 1256 | 1318 | assert!(back_run(&written).system); | |
| 1257 | 1319 | } | |
| 1258 | 1320 | ||
| 1321 | + | fn session_scope() -> AgentScope { | |
| 1322 | + | AgentScope { | |
| 1323 | + | repo: path("acme", "rocket"), | |
| 1324 | + | operations: operations_for(K::Session, Runner).into_iter().map(str::to_owned).collect(), | |
| 1325 | + | run: Some(RunBinding { | |
| 1326 | + | kind: K::Session, | |
| 1327 | + | usage: Runner, | |
| 1328 | + | run_id: None, | |
| 1329 | + | number: None, | |
| 1330 | + | agent: "g1t".to_owned(), | |
| 1331 | + | system: false, | |
| 1332 | + | read: Vec::new(), | |
| 1333 | + | push: vec![GitGrant { repo: path("acme", "rocket"), branch: None }], | |
| 1334 | + | }), | |
| 1335 | + | } | |
| 1336 | + | } | |
| 1337 | + | ||
| 1338 | + | /// A session works on its issue through the API and MCP with what its | |
| 1339 | + | /// scopes allow, in its repository, and never with what no run may. | |
| 1340 | + | #[test] | |
| 1341 | + | fn a_session_does_what_its_scopes_allow_in_its_repository() { | |
| 1342 | + | let scope = session_scope(); | |
| 1343 | + | let user = agent(&["acme"], scope.clone()); | |
| 1344 | + | let rocket = path("acme", "rocket"); | |
| 1345 | + | let decide = |operation: &str, repo: &RepoPath| decide_operation(&user, &scope, operation, Some(repo), true, Some(1)); | |
| 1346 | + | for operation in [ | |
| 1347 | + | "whoami", | |
| 1348 | + | "get_work", | |
| 1349 | + | "claim_issue", | |
| 1350 | + | "add_comment", | |
| 1351 | + | "close_issue", | |
| 1352 | + | "create_pull_request", | |
| 1353 | + | "review_pull_request", | |
| 1354 | + | "mark_pull_request_ready", | |
| 1355 | + | "record_session", | |
| 1356 | + | "update_session", | |
| 1357 | + | "get_workflow_run", | |
| 1358 | + | ] { | |
| 1359 | + | let decision = decide(operation, &rocket); | |
| 1360 | + | assert!(decision.allowed, "{operation}: {:?}", decision.reason); | |
| 1361 | + | assert_eq!(decision.rule, "run:session/runner"); | |
| 1362 | + | } | |
| 1363 | + | assert_eq!(decide("add_comment", &path("acme", "billing")).rule, "scope:repository"); | |
| 1364 | + | for operation in ["merge_pull_request", "update_repo_settings", "set_actions_secret", "list_members", "delete_repo"] { | |
| 1365 | + | assert_eq!(decide(operation, &rocket).rule, "never", "{operation}"); | |
| 1366 | + | } | |
| 1367 | + | // What its scopes do not reach: workspaces' settings, deleting. | |
| 1368 | + | for operation in ["update_workspace", "delete_package", "create_webhook"] { | |
| 1369 | + | assert!(!decide(operation, &rocket).allowed, "{operation}"); | |
| 1370 | + | } | |
| 1371 | + | use crate::scopes::Scope; | |
| 1372 | + | let scopes = run_scopes(K::Session, Runner); | |
| 1373 | + | assert!(scopes.contains(&Scope::CodeWrite) && scopes.contains(&Scope::IssuesWrite)); | |
| 1374 | + | assert!(scopes.iter().all(|scope| !scope.dangerous())); | |
| 1375 | + | } | |
| 1376 | + | ||
| 1377 | + | /// It pushes to its repository, any branch (a new repository's first | |
| 1378 | + | /// version goes to its default branch), and to the working copies of | |
| 1379 | + | /// that repository's pull requests, made after it was. | |
| 1380 | + | #[test] | |
| 1381 | + | fn a_session_pushes_to_its_repository_and_its_pull_requests() { | |
| 1382 | + | let session = session_scope(); | |
| 1383 | + | let rocket = path("acme", "rocket"); | |
| 1384 | + | let copy = path("pulls", "pul_9"); | |
| 1385 | + | assert!(decide_git(&session, &rocket, true).allowed); | |
| 1386 | + | assert!(!limits_branches(&session, &rocket)); | |
| 1387 | + | assert!(decide_git_at(&session, ©, Some(&rocket), true).allowed); | |
| 1388 | + | assert!(decide_git_at(&session, ©, Some(&rocket), false).allowed); | |
| 1389 | + | assert_eq!(decide_git(&session, ©, true).rule, "git:push"); | |
| 1390 | + | assert_eq!(decide_git_at(&session, ©, Some(&path("acme", "billing")), true).rule, "git:push"); | |
| 1391 | + | assert_eq!(decide_git(&session, &path("acme", "billing"), false).rule, "git:read"); | |
| 1392 | + | // Another kind of run never reaches a pull request's copy this way. | |
| 1393 | + | assert!(!decide_git_at(&scope(K::Update, Runner), ©, Some(&rocket), true).allowed); | |
| 1394 | + | } | |
| 1395 | + | ||
| 1396 | + | /// Downstream it is the person, so the session and claims it records | |
| 1397 | + | /// are theirs, and it still says it is g1t acting for them, so what it | |
| 1398 | + | /// writes is g1t's. | |
| 1399 | + | #[test] | |
| 1400 | + | fn a_session_acts_downstream_as_the_person_through_g1t() { | |
| 1401 | + | let user = agent(&["acme"], session_scope()); | |
| 1402 | + | let person = as_person(&user).unwrap(); | |
| 1403 | + | assert_eq!((person.id.as_str(), person.kind), ("usr_1", PrincipalKind::User)); | |
| 1404 | + | let acting = person.acting.as_ref().expect("it says it is g1t acting for them"); | |
| 1405 | + | assert_eq!((acting.agent.as_str(), acting.on_behalf_of.username.as_str()), ("g1t", "syntaqx")); | |
| 1406 | + | assert_eq!(describe(&person), "g1t on behalf of syntaqx"); | |
| 1407 | + | let (author, asked) = crate::work::authorship(&person); | |
| 1408 | + | assert_eq!(author.username, "g1t"); | |
| 1409 | + | assert_eq!(asked.map(|asked| asked.username).as_deref(), Some("syntaqx")); | |
| 1410 | + | } | |
| 1411 | + | ||
| 1259 | 1412 | fn back_run(written: &str) -> RunBinding { | |
| 1260 | 1413 | serde_json::from_str::<AgentScope>(written).unwrap().run.unwrap() | |
| 1261 | 1414 | } | |
| 659 | 659 | /// Who is recorded as opening an issue or a pull request that `actor` | |
| 660 | 660 | /// opens, and who asked for it: `(author, requested_by)`. | |
| 661 | 661 | /// | |
| 662 | − | /// - g1t's retired agent, acting for someone through a token of its own | |
| 662 | + | /// - A session g1t started for someone, which acts as that person with | |
| 663 | + | /// g1t's credential (`credentials::as_person` keeps its `acting`), and | |
| 664 | + | /// g1t's retired agent, acting for someone through a token of its own | |
| 663 | 665 | /// that still works: g1t, requested by that person. | |
| 664 | 666 | /// - Anyone else, and g1t's own work that nobody asked for: the actor, and | |
| 665 | 667 | /// nobody asking. | |
| 668 | + | /// | |
| 669 | + | /// What a session writes in a conversation (comments, reviews, what it | |
| 670 | + | /// closes) is recorded the same way: g1t, acting for the person. | |
| 666 | 671 | pub fn authorship(actor: &User) -> (User, Option<User>) { | |
| 667 | 672 | let person = |id: &str, username: &str| User { | |
| 668 | 673 | id: id.to_owned(), | |
| ⋯ | |||
| 670 | 675 | kind: crate::PrincipalKind::User, | |
| 671 | 676 | ..User::default() | |
| 672 | 677 | }; | |
| 673 | − | if actor.kind == crate::PrincipalKind::Agent { | |
| 678 | + | if actor.kind == crate::PrincipalKind::Agent || actor.acting.is_some() { | |
| 674 | 679 | let asked = actor | |
| 675 | 680 | .acting | |
| 676 | 681 | .as_ref() | |
| ⋯ | |||
| 904 | 909 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 905 | 910 | pub agent: Option<AgentRef>, | |
| 906 | 911 | /// Who the agent acted for: the person whose access it had, who | |
| 907 | − | /// answers for it as its author would. Set with `agent`. | |
| 912 | + | /// answers for it as its author would. Set with `agent`, and when g1t | |
| 913 | + | /// wrote it (or did what an event notes) in a session someone started. | |
| 908 | 914 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 909 | 915 | pub acting_for: Option<User>, | |
| 910 | 916 | /// An agent's review: shown with its verdict (or none, for a review | |
| 24 | 24 | | "queue" | |
| 25 | 25 | | "mergecheck" | |
| 26 | 26 | | "deploy" | |
| 27 | − | | "bump"; | |
| 27 | + | | "bump" | |
| 28 | + | /** A session g1t started on an agent's harness: g1t acting for the person, in one repository. */ | |
| 29 | + | | "session"; | |
| 28 | 30 | ||
| 29 | 31 | /** | |
| 30 | 32 | * `runner`: g1t's runner in the sandbox, which clones, pushes and records | |
| 31 | − | * the session, acting downstream as the person. `tools`: the agent's own | |
| 32 | − | * MCP tools, acting as the agent. | |
| 33 | + | * the session, acting downstream as the person (a session's, also over the | |
| 34 | + | * API, MCP and the CLI, as g1t for them). `tools`: the agent's own MCP | |
| 35 | + | * tools, acting as the agent. | |
| 33 | 36 | */ | |
| 34 | 37 | export type CredentialUse = "runner" | "tools"; | |
| 35 | 38 |
| 333 | 333 | * show it by `agent.displayName` with an Agent badge, never as a person. | |
| 334 | 334 | */ | |
| 335 | 335 | agent?: AgentRef; | |
| 336 | − | /** Who the agent acted for, whose access capped it. Set with `agent`. */ | |
| 336 | + | /** | |
| 337 | + | * Who the agent acted for, whose access capped it. Set with `agent`, and | |
| 338 | + | * when g1t (`author`) wrote it, or did what an event notes, in a session | |
| 339 | + | * someone started: show "on behalf of @them". | |
| 340 | + | */ | |
| 337 | 341 | actingFor?: User; | |
| 338 | 342 | /** | |
| 339 | 343 | * An agent's review. Its `verdict` (none for a review that only |
| 1 | 1 | //! Run credentials: a token per sandbox run (checks, the merge queue, a | |
| 2 | − | //! merge check, a deployment or a security update's bump), bound to the | |
| 2 | + | //! merge check, a deployment, a security update's bump, or a session g1t | |
| 3 | + | //! started on an agent's harness), bound to the | |
| 3 | 4 | //! run, its repository and what its kind of work needs, acting as g1t on | |
| 4 | 5 | //! behalf of the person who started the work, or of the workspace for | |
| 5 | 6 | //! g1t's own. See `g1t_contracts::credentials` for the policy. | |
| ⋯ | |||
| 24 | 25 | ||
| 25 | 26 | /// No run outlives this, whatever its caller asks for. | |
| 26 | 27 | const MAX_RUN_TTL_SECONDS: u64 = 6 * 60 * 60; | |
| 28 | + | /// Nor a session on an agent's harness this, which is stopped by then | |
| 29 | + | /// (integrations' agent_sessions.rs) and its credential revoked when it ends. | |
| 30 | + | const MAX_SESSION_TTL_SECONDS: u64 = 12 * 60 * 60; | |
| 27 | 31 | const MIN_RUN_TTL_SECONDS: u64 = 60; | |
| 28 | 32 | /// More hashes than one sandbox ever holds. | |
| 29 | 33 | const MAX_HASHES: usize = 8; | |
| ⋯ | |||
| 35 | 39 | !kind.retired() && usage == CredentialUse::Runner | |
| 36 | 40 | } | |
| 37 | 41 | ||
| 42 | + | /// How long a credential of this kind may last. | |
| 43 | + | fn max_ttl(kind: RunCredentialKind) -> u64 { | |
| 44 | + | match kind { | |
| 45 | + | RunCredentialKind::Session => MAX_SESSION_TTL_SECONDS, | |
| 46 | + | _ => MAX_RUN_TTL_SECONDS, | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 38 | 50 | /// A SHA-256 in lowercase hex, as tokens are stored. | |
| 39 | 51 | fn is_hash(value: &str) -> bool { | |
| 40 | 52 | value.len() == 64 | |
| ⋯ | |||
| 164 | 176 | ), | |
| 165 | 177 | ttl_seconds: Some( | |
| 166 | 178 | a.ttl_seconds | |
| 167 | − | .clamp(MIN_RUN_TTL_SECONDS, MAX_RUN_TTL_SECONDS), | |
| 179 | + | .clamp(MIN_RUN_TTL_SECONDS, max_ttl(a.kind)), | |
| 168 | 180 | ), | |
| 169 | 181 | // A run credential's scope is its run's; see agent_scope. | |
| 170 | 182 | scopes: None, | |
| ⋯ | |||
| 261 | 273 | .filter(|kind| issued_for(**kind, CredentialUse::Runner)) | |
| 262 | 274 | .map(|kind| kind.as_str()) | |
| 263 | 275 | .collect(); | |
| 264 | − | assert_eq!(issued, ["checks", "queue", "mergecheck", "deploy", "bump"]); | |
| 276 | + | assert_eq!(issued, ["checks", "queue", "mergecheck", "deploy", "bump", "session"]); | |
| 265 | 277 | // A retired hosted agent's tools token is never made. | |
| 266 | 278 | assert!(RunCredentialKind::ALL.iter().all(|kind| !issued_for(*kind, CredentialUse::Tools))); | |
| 267 | 279 | } | |
| 268 | 280 | ||
| 281 | + | /// A session's credential lasts as long as a session may run; every | |
| 282 | + | /// other run's no longer than six hours. | |
| 283 | + | #[test] | |
| 284 | + | fn a_session_s_credential_lasts_as_long_as_it_may_run() { | |
| 285 | + | assert_eq!(super::max_ttl(RunCredentialKind::Session), 12 * 60 * 60); | |
| 286 | + | assert_eq!(super::max_ttl(RunCredentialKind::Checks), 6 * 60 * 60); | |
| 287 | + | } | |
| 288 | + | ||
| 269 | 289 | #[test] | |
| 270 | 290 | fn only_hashes_are_taken() { | |
| 271 | 291 | assert!(is_hash(&"a1".repeat(32))); | |
| 4 | 4 | //! g1t runs no model. With the workspace's Anthropic connection, g1t starts | |
| 5 | 5 | //! a Claude Managed Agents session with that key: it keeps one agent and one | |
| 6 | 6 | //! environment on the account (made once, updated when what g1t asks of them | |
| 7 | − | //! changes), and for each session a vault holding a g1t token made for the | |
| 8 | − | //! person who started it and that repository only, which lasts the session: | |
| 9 | − | //! the MCP server's credential (`static_bearer`) and `G1T_TOKEN` for git and | |
| 7 | + | //! changes), and for each session a vault holding a g1t token, which lasts | |
| 8 | + | //! the session: a session's run credential (`credentials::RunCredentialKind::Session`), | |
| 9 | + | //! g1t acting on behalf of the person who started it, in that repository | |
| 10 | + | //! only, so what it writes on g1t (comments, reviews, closes, the issues and | |
| 11 | + | //! pull requests it opens) shows as g1t's for them, while what it may do is | |
| 12 | + | //! their role there. It is the MCP server's credential (`static_bearer`) and `G1T_TOKEN` for git and | |
| 10 | 13 | //! the CLI (`environment_variable`, swapped in on the way out). The model and | |
| 11 | 14 | //! the sandbox never see the token, and it is never in the prompt; the key | |
| 12 | 15 | //! never leaves this service. g1t's own skills (work-on-issue, coordinate, | |
| ⋯ | |||
| 32 | 35 | use std::collections::BTreeMap; | |
| 33 | 36 | ||
| 34 | 37 | use g1t_contracts::agent_sessions::*; | |
| 35 | − | use g1t_contracts::identity::{CreateAccessTokenArgs, CreatedAccessToken, RemoveArgs}; | |
| 38 | + | use g1t_contracts::credentials::{CreateRunCredentialArgs, CredentialUse, GitGrant, RunCredentialKind}; | |
| 39 | + | use g1t_contracts::identity::{CreatedAccessToken, RemoveArgs}; | |
| 36 | 40 | use g1t_contracts::ai::AiProvider; | |
| 37 | 41 | use g1t_contracts::repos::RepoPath; | |
| 38 | 42 | use g1t_contracts::time::{parse_rfc3339, rfc3339}; | |
| ⋯ | |||
| 65 | 69 | const TOKEN_TTL_SECONDS: u64 = 12 * 60 * 60; | |
| 66 | 70 | /// A session still going after this long is stopped. | |
| 67 | 71 | const MAX_RUN_MS: u64 = 12 * 60 * 60 * 1000; | |
| 68 | − | /// The scopes of the token a session holds: what working on an issue needs. | |
| 69 | − | const SCOPES: [&str; 8] = [ | |
| 70 | − | "repo:read", | |
| 71 | − | "code:write", | |
| 72 | − | "issues:write", | |
| 73 | − | "pull_requests:write", | |
| 74 | − | "checks:read", | |
| 75 | − | "workflows:read", | |
| 76 | − | "deployments:read", | |
| 77 | − | "observability:read", | |
| 78 | − | ]; | |
| 79 | 72 | /// The most events read from one stream in one look. | |
| 80 | 73 | const PAGE: u32 = 100; | |
| 81 | 74 | const MAX_PAGES: usize = 5; | |
| ⋯ | |||
| 109 | 102 | - Use the g1t CLI from the clone, as the work-on-issue skill does: `g1t work context N`, `g1t work claim N --note …`, \ | |
| 110 | 103 | `g1t pr create --issue N --draft --fill`, `g1t pr checks --watch --fail-fast`, `g1t run logs --failed`. \ | |
| 111 | 104 | Add `--json` when you need fields; `g1t <command> --help` says the rest.\n\ | |
| 112 | − | - g1t's MCP server (the `g1t` tools) does the same as that person on that repository only. Use it when the CLI has no command for something.\n\ | |
| 105 | + | - g1t's MCP server (the `g1t` tools) does the same, on that repository only. Use it when the CLI has no command for something.\n\ | |
| 106 | + | - Your token is g1t's, acting for that person: what you do on g1t shows as g1t's on their behalf, and you may do what they may there.\n\ | |
| 113 | 107 | - `G1T_TOKEN` is set for the CLI and git. In the sandbox it is a placeholder that becomes the token only in a header on the way to g1t, \ | |
| 114 | 108 | never in a URL. Never print it or send it anywhere else.\n\ | |
| 115 | 109 | - Follow the work-on-issue skill. Check the work context and the sessions on the issue before you start, and claim only what you take. \ | |
| ⋯ | |||
| 123 | 117 | `g1t deploy list` until it is live or failed; if it failed, read why, fix it and push again. Say where it is live in your report.\n\ | |
| 124 | 118 | - Never write `@g1t` in what you post on g1t: that asks g1t for another session.\n\ | |
| 125 | 119 | - When you finish, say in a few sentences what you did, what is left, and the pull request, if any. That is your report on g1t: it shows on the issue as this session's card, so never post it, \ | |
| 126 | − | or anything else, as a comment. Your token is the person's, and a comment would read as theirs." | |
| 120 | + | or anything else, as a comment." | |
| 127 | 121 | .to_owned() | |
| 128 | 122 | } | |
| 129 | 123 | ||
| ⋯ | |||
| 1040 | 1034 | Some((number, title, _)) => format!("#{number} {}", title.trim()), | |
| 1041 | 1035 | None => format!("#{} {}", issue.number, issue.title.trim()), | |
| 1042 | 1036 | }; | |
| 1043 | − | // The token the session holds: this person, this repository, until it ends. | |
| 1037 | + | // The token the session holds: g1t acting for this person, in this | |
| 1038 | + | // repository, until it ends (a session's run credential), so what | |
| 1039 | + | // it writes on g1t is g1t's for them, and its pushes are theirs. | |
| 1044 | 1040 | let created: CreatedAccessToken = g1t_kit::call( | |
| 1045 | 1041 | &self.identity, | |
| 1046 | − | "create_access_token", | |
| 1047 | − | &CreateAccessTokenArgs { | |
| 1048 | − | user: actor.clone(), | |
| 1049 | − | name: format!("Claude Managed Agents session on {repo_name}#{}", issue.number), | |
| 1050 | − | ttl_seconds: Some(TOKEN_TTL_SECONDS), | |
| 1051 | − | scopes: Some(SCOPES.iter().map(|scope| (*scope).to_owned()).collect()), | |
| 1052 | − | listed: false, | |
| 1053 | − | repo: Some(repo_name.clone()), | |
| 1042 | + | "create_run_credential", | |
| 1043 | + | &CreateRunCredentialArgs { | |
| 1044 | + | on_behalf_of: actor.clone(), | |
| 1045 | + | repo: repo.clone(), | |
| 1046 | + | kind: RunCredentialKind::Session, | |
| 1047 | + | usage: CredentialUse::Runner, | |
| 1048 | + | number: None, | |
| 1049 | + | read: Vec::new(), | |
| 1050 | + | push: vec![GitGrant { repo: repo.clone(), branch: None }], | |
| 1051 | + | ttl_seconds: TOKEN_TTL_SECONDS, | |
| 1054 | 1052 | }, | |
| 1055 | 1053 | ) | |
| 1056 | 1054 | .await?; | |
| 2 | 2 | //! | |
| 3 | 3 | //! A sandbox's runner clones, fetches and pushes with a credential bound to | |
| 4 | 4 | //! its run (see `g1t_contracts::credentials`): it may read the repositories | |
| 5 | − | //! its run needs, push only to its pull request's fork or branch, and acts | |
| 5 | + | //! its run needs, push only to its pull request's fork or branch (a | |
| 6 | + | //! session's: its repository and its pull requests' forks), and acts | |
| 6 | 7 | //! as the person it works for once let through, so the repository's own | |
| 7 | 8 | //! rules then apply to them too. Every git request a run credential makes | |
| 8 | 9 | //! is recorded, and so is every push, by anyone. | |
| 9 | 10 | ||
| 10 | 11 | use g1t_contracts::audit::{AuditActor, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface}; | |
| 11 | − | use g1t_contracts::credentials::{Decision, as_person, decide_git, decide_refs, limits_branches}; | |
| 12 | + | use g1t_contracts::credentials::{Decision, RunCredentialKind, as_person, decide_git_at, decide_refs, limits_branches}; | |
| 12 | 13 | use g1t_contracts::repos::{GitService, Repo, RepoPath}; | |
| 13 | 14 | use g1t_contracts::{PrincipalKind, Viewer}; | |
| 14 | 15 | use worker::js_sys::Uint8Array; | |
| ⋯ | |||
| 213 | 214 | }); | |
| 214 | 215 | }; | |
| 215 | 216 | ||
| 216 | − | let mut decision = decide_git(&scope, &git.path, write); | |
| 217 | + | // A session's credential reaches its repository's pull requests' | |
| 218 | + | // working copies too, made after it was: by what they copy. | |
| 219 | + | let source = match found.and_then(|repo| repo.fork_of.as_deref()) { | |
| 220 | + | Some(source_id) if scope.run.as_ref().is_some_and(|run| run.kind == RunCredentialKind::Session) => self | |
| 221 | + | .registry | |
| 222 | + | .by_id(source_id) | |
| 223 | + | .await? | |
| 224 | + | .map(|source| RepoPath { namespace: source.namespace, name: source.name }), | |
| 225 | + | _ => None, | |
| 226 | + | }; | |
| 227 | + | let mut decision = decide_git_at(&scope, &git.path, source.as_ref(), write); | |
| 217 | 228 | let mut refs = Vec::new(); | |
| 218 | 229 | if decision.allowed && post && write && limits_branches(&scope, &git.path) { | |
| 219 | 230 | let body = request.bytes().await?; | |
| 111 | 111 | return Ok(()); | |
| 112 | 112 | } | |
| 113 | 113 | let list = |numbers: &[u32]| numbers.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", "); | |
| 114 | − | let who = (actor.id.as_str(), actor.username.as_str()); | |
| 114 | + | let who = actor; | |
| 115 | 115 | if !added.is_empty() { | |
| 116 | 116 | self.note(&issue.repo_id, issue.number, who, &format!("marked this as blocked by {}", list(&added))).await?; | |
| 117 | 117 | } |
| 472 | 472 | removed: &[String], | |
| 473 | 473 | colors: &[(String, String)], | |
| 474 | 474 | ) -> Result<()> { | |
| 475 | − | let who = (actor.id.as_str(), actor.username.as_str()); | |
| 475 | + | let who = actor; | |
| 476 | 476 | if !added.is_empty() { | |
| 477 | 477 | self.note(item.repo_id(), item.number(), who, &label_note("added", added)).await?; | |
| 478 | 478 | } |
| 400 | 400 | ||
| 401 | 401 | /// Records something that happened to an issue or a pull request, so | |
| 402 | 402 | /// that it shows in the conversation where it happened. `text` is what | |
| 403 | − | /// `author` did, as the rest of a sentence starting with their name. | |
| 404 | − | pub(crate) async fn note( | |
| 403 | + | /// `author` did, as the rest of a sentence starting with their name: | |
| 404 | + | /// an id and a name, or someone acting, as [`rows::By`] records them. | |
| 405 | + | pub(crate) async fn note<'a>( | |
| 405 | 406 | &self, | |
| 406 | 407 | repo_id: &str, | |
| 407 | 408 | number: u32, | |
| 408 | − | author: (&str, &str), | |
| 409 | + | author: impl Into<rows::By<'a>>, | |
| 409 | 410 | text: &str, | |
| 410 | 411 | ) -> Result<()> { | |
| 412 | + | let author = author.into(); | |
| 411 | 413 | let now = now_ms(); | |
| 412 | 414 | self.db | |
| 413 | 415 | .prepare( | |
| 414 | 416 | "INSERT INTO comments | |
| 415 | − | (id, repo_id, number, author_id, author_name, body, kind, created_at) | |
| 416 | − | VALUES (?, ?, ?, ?, ?, ?, 'event', ?)", | |
| 417 | + | (id, repo_id, number, author_id, author_name, body, kind, created_at, | |
| 418 | + | acting_for_id, acting_for_name) | |
| 419 | + | VALUES (?, ?, ?, ?, ?, ?, 'event', ?, ?, ?)", | |
| 417 | 420 | ) | |
| 418 | 421 | .bind(&[ | |
| 419 | 422 | new_id("cmt", now).into(), | |
| 420 | 423 | repo_id.into(), | |
| 421 | 424 | number.into(), | |
| 422 | − | author.0.into(), | |
| 423 | − | author.1.into(), | |
| 425 | + | author.id.into(), | |
| 426 | + | author.name.into(), | |
| 424 | 427 | text.into(), | |
| 425 | 428 | rfc3339(now).into(), | |
| 429 | + | author.acting_for.map_or(JsValue::NULL, |(id, _)| id.into()), | |
| 430 | + | author.acting_for.map_or(JsValue::NULL, |(_, name)| name.into()), | |
| 426 | 431 | ])? | |
| 427 | 432 | .run() | |
| 428 | 433 | .await?; | |
| ⋯ | |||
| 449 | 454 | }; | |
| 450 | 455 | let new: Vec<&String> = after.iter().filter(|name| !before.contains(name)).collect(); | |
| 451 | 456 | let gone: Vec<&String> = before.iter().filter(|name| !after.contains(name)).collect(); | |
| 452 | − | let who = (actor.id.as_str(), actor.username.as_str()); | |
| 457 | + | let who = actor; | |
| 453 | 458 | if !new.is_empty() { | |
| 454 | − | // Taking something on oneself reads better said that way. | |
| 455 | − | let text = if added == "assigned" && new == [&actor.username] { | |
| 459 | + | // Taking something on oneself reads better said that way (not | |
| 460 | + | // when g1t, acting for them, did it). | |
| 461 | + | let text = if added == "assigned" && new == [&actor.username] && actor.acting.is_none() { | |
| 456 | 462 | "self-assigned this".to_owned() | |
| 457 | 463 | } else { | |
| 458 | 464 | format!("{added} {}", joined(new)) | |
| ⋯ | |||
| 928 | 934 | self.note( | |
| 929 | 935 | &issue.repo_id, | |
| 930 | 936 | issue.number, | |
| 931 | − | (&a.actor.id, &a.actor.username), | |
| 937 | + | &a.actor, | |
| 932 | 938 | &pushed::closed_note(note, session.as_deref()), | |
| 933 | 939 | ) | |
| 934 | 940 | .await?; | |
| ⋯ | |||
| 970 | 976 | self.note( | |
| 971 | 977 | &issue.repo_id, | |
| 972 | 978 | issue.number, | |
| 973 | − | (&a.actor.id, &a.actor.username), | |
| 979 | + | &a.actor, | |
| 974 | 980 | "reopened this", | |
| 975 | 981 | ) | |
| 976 | 982 | .await?; | |
| ⋯ | |||
| 1062 | 1068 | }; | |
| 1063 | 1069 | ||
| 1064 | 1070 | let now = now_ms(); | |
| 1071 | + | // A session g1t started for someone writes as g1t, for them. | |
| 1072 | + | let by = rows::By::from(&a.actor); | |
| 1065 | 1073 | let comment = Comment { | |
| 1066 | 1074 | kind: CommentKind::Comment, | |
| 1067 | 1075 | id: new_id("cmt", now), | |
| 1068 | − | author: a.actor.clone(), | |
| 1076 | + | author: if by.id == a.actor.id { a.actor.clone() } else { rows::user(by.id.to_owned(), by.name.to_owned()) }, | |
| 1069 | 1077 | body: body.to_owned(), | |
| 1070 | 1078 | path: path.map(str::to_owned), | |
| 1071 | 1079 | line, | |
| ⋯ | |||
| 1073 | 1081 | created_at: rfc3339(now), | |
| 1074 | 1082 | edited_at: None, | |
| 1075 | 1083 | agent: None, | |
| 1076 | − | acting_for: None, | |
| 1084 | + | acting_for: by.acting_for.map(|(id, name)| rows::user(id.to_owned(), name.to_owned())), | |
| 1077 | 1085 | advisory: false, | |
| 1078 | 1086 | reactions: Vec::new(), | |
| 1079 | 1087 | }; | |
| ⋯ | |||
| 1083 | 1091 | .prepare( | |
| 1084 | 1092 | "INSERT INTO comments | |
| 1085 | 1093 | (id, repo_id, number, author_id, author_name, body, path, line, | |
| 1086 | − | verdict, created_at) | |
| 1087 | − | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 1094 | + | verdict, created_at, acting_for_id, acting_for_name) | |
| 1095 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 1088 | 1096 | ) | |
| 1089 | 1097 | .bind(&[ | |
| 1090 | 1098 | comment.id.as_str().into(), | |
| 1091 | 1099 | repo.id.as_str().into(), | |
| 1092 | 1100 | a.number.into(), | |
| 1093 | − | a.actor.id.as_str().into(), | |
| 1094 | − | a.actor.username.as_str().into(), | |
| 1101 | + | by.id.into(), | |
| 1102 | + | by.name.into(), | |
| 1095 | 1103 | body.into(), | |
| 1096 | 1104 | optional(&comment.path), | |
| 1097 | 1105 | optional_number(line), | |
| 1098 | 1106 | a.verdict | |
| 1099 | 1107 | .map_or(JsValue::NULL, |verdict| verdict.as_str().into()), | |
| 1100 | 1108 | comment.created_at.as_str().into(), | |
| 1109 | + | by.acting_for.map_or(JsValue::NULL, |(id, _)| id.into()), | |
| 1110 | + | by.acting_for.map_or(JsValue::NULL, |(_, name)| name.into()), | |
| 1101 | 1111 | ])?, | |
| 1102 | 1112 | self.db | |
| 1103 | 1113 | .prepare(format!( | |
| ⋯ | |||
| 1459 | 1469 | fill_base(&mut pull, &repo); | |
| 1460 | 1470 | if let Some(issue) = pull.issue { | |
| 1461 | 1471 | let text = format!("opened #{} for this", pull.number); | |
| 1462 | − | self.note(&repo.id, issue, (&a.actor.id, &a.actor.username), &text) | |
| 1472 | + | self.note(&repo.id, issue, &a.actor, &text) | |
| 1463 | 1473 | .await?; | |
| 1464 | 1474 | } | |
| 1465 | 1475 | // Its head and merge refs in the repository, before its events go out. | |
| ⋯ | |||
| 1966 | 1976 | self.note( | |
| 1967 | 1977 | &pull.repo_id, | |
| 1968 | 1978 | pull.number, | |
| 1969 | − | (&actor.id, &actor.username), | |
| 1979 | + | actor, | |
| 1970 | 1980 | &format!("changed the base branch from `{before}` to `{base}`"), | |
| 1971 | 1981 | ) | |
| 1972 | 1982 | .await?; | |
| ⋯ | |||
| 2036 | 2046 | self.note( | |
| 2037 | 2047 | &pull.repo_id, | |
| 2038 | 2048 | pull.number, | |
| 2039 | − | (&a.actor.id, &a.actor.username), | |
| 2049 | + | &a.actor, | |
| 2040 | 2050 | "marked this ready for review", | |
| 2041 | 2051 | ) | |
| 2042 | 2052 | .await?; | |
| ⋯ | |||
| 2078 | 2088 | self.note( | |
| 2079 | 2089 | &pull.repo_id, | |
| 2080 | 2090 | pull.number, | |
| 2081 | − | (&a.actor.id, &a.actor.username), | |
| 2091 | + | &a.actor, | |
| 2082 | 2092 | "closed this", | |
| 2083 | 2093 | ) | |
| 2084 | 2094 | .await?; | |
| ⋯ | |||
| 2149 | 2159 | self.note( | |
| 2150 | 2160 | &pull.repo_id, | |
| 2151 | 2161 | pull.number, | |
| 2152 | − | (&a.actor.id, &a.actor.username), | |
| 2162 | + | &a.actor, | |
| 2153 | 2163 | "reopened this", | |
| 2154 | 2164 | ) | |
| 2155 | 2165 | .await?; | |
| ⋯ | |||
| 2197 | 2207 | self.note( | |
| 2198 | 2208 | &pull.repo_id, | |
| 2199 | 2209 | pull.number, | |
| 2200 | − | (&a.actor.id, &a.actor.username), | |
| 2210 | + | &a.actor, | |
| 2201 | 2211 | "marked this as a draft", | |
| 2202 | 2212 | ) | |
| 2203 | 2213 | .await?; | |
| 336 | 336 | .run() | |
| 337 | 337 | .await?; | |
| 338 | 338 | if let Some(text) = milestone_note(before.as_ref(), after.as_ref()) { | |
| 339 | − | self.note(&repo.id, item.number(), (&actor.id, &actor.username), &text).await?; | |
| 339 | + | self.note(&repo.id, item.number(), actor, &text).await?; | |
| 340 | 340 | } | |
| 341 | 341 | for (what, milestone) in [("demilestoned", before), ("milestoned", after)] { | |
| 342 | 342 | let Some(milestone) = milestone else { continue }; |
| 33 | 33 | Some(user(id?, username?)) | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | + | /// Who wrote a comment or did what an event notes, as stored: g1t, and | |
| 37 | + | /// whom it acted for, when a session g1t started for someone did it (as | |
| 38 | + | /// `work::authorship` records what it opens); whoever it was otherwise. | |
| 39 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 40 | + | pub(crate) struct By<'a> { | |
| 41 | + | pub id: &'a str, | |
| 42 | + | pub name: &'a str, | |
| 43 | + | /// The person's id and username. | |
| 44 | + | pub acting_for: Option<(&'a str, &'a str)>, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | impl<'a> From<(&'a str, &'a str)> for By<'a> { | |
| 48 | + | fn from((id, name): (&'a str, &'a str)) -> Self { | |
| 49 | + | By { id, name, acting_for: None } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | impl<'a> From<(&'a String, &'a String)> for By<'a> { | |
| 54 | + | fn from((id, name): (&'a String, &'a String)) -> Self { | |
| 55 | + | By { id, name, acting_for: None } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | impl<'a> From<&'a User> for By<'a> { | |
| 60 | + | fn from(actor: &'a User) -> Self { | |
| 61 | + | if actor.kind == g1t_contracts::PrincipalKind::Agent || actor.acting.is_some() { | |
| 62 | + | let acting_for = actor | |
| 63 | + | .acting | |
| 64 | + | .as_deref() | |
| 65 | + | .map(|acting| &acting.on_behalf_of) | |
| 66 | + | .filter(|person| !g1t_contracts::system::is_system_id(&person.id)) | |
| 67 | + | .map(|person| (person.id.as_str(), person.username.as_str())); | |
| 68 | + | return By { id: g1t_contracts::identity::AGENT_ID, name: g1t_contracts::identity::AGENT_NAME, acting_for }; | |
| 69 | + | } | |
| 70 | + | By { id: &actor.id, name: &actor.username, acting_for: None } | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 36 | 74 | #[derive(Deserialize)] | |
| 37 | 75 | pub struct IssueRow { | |
| 38 | 76 | pub id: String, | |
| ⋯ | |||
| 324 | 362 | _ => None, | |
| 325 | 363 | }; | |
| 326 | 364 | let advisory_verdict = row.agent_verdict.as_deref().and_then(AgentVerdict::parse); | |
| 365 | + | // Whom it acted for: a former workspace agent's, or g1t's in a | |
| 366 | + | // session someone started. | |
| 367 | + | let acted = agent.is_some() || row.author_id == g1t_contracts::identity::AGENT_ID; | |
| 327 | 368 | let mut author = user(row.author_id, row.author_name); | |
| 328 | 369 | if agent.is_some() { | |
| 329 | 370 | author.kind = g1t_contracts::PrincipalKind::Agent; | |
| ⋯ | |||
| 339 | 380 | verdict: row.verdict.or(advisory_verdict.and_then(AgentVerdict::verdict)), | |
| 340 | 381 | created_at: row.created_at, | |
| 341 | 382 | edited_at: row.edited_at, | |
| 342 | − | acting_for: agent.as_ref().and(requester(row.acting_for_id, row.acting_for_name)), | |
| 383 | + | acting_for: if acted { requester(row.acting_for_id, row.acting_for_name) } else { None }, | |
| 343 | 384 | advisory: agent.is_some() && advisory_verdict.is_some(), | |
| 344 | 385 | agent, | |
| 345 | 386 | reactions: Vec::new(), | |
| ⋯ | |||
| 453 | 494 | assert!(requester(Some("usr_1".into()), None).is_none()); | |
| 454 | 495 | } | |
| 455 | 496 | ||
| 497 | + | /// A session g1t started for someone acts as them (credentials::as_person) | |
| 498 | + | /// with what it acts as kept: what it writes is g1t's, for them, and | |
| 499 | + | /// reads back that way, so they can still edit it. | |
| 500 | + | #[test] | |
| 501 | + | fn a_session_s_comment_is_g1t_s_for_the_person() { | |
| 502 | + | use g1t_contracts::credentials::{Acting, Principal}; | |
| 503 | + | let person = User { id: "usr_1".into(), username: "syntaqx".into(), ..User::default() }; | |
| 504 | + | assert_eq!(By::from(&person), By { id: "usr_1", name: "syntaqx", acting_for: None }); | |
| 505 | + | let session = User { | |
| 506 | + | acting: Some(Box::new(Acting { | |
| 507 | + | credential_id: "tok_1".into(), | |
| 508 | + | agent: "g1t".into(), | |
| 509 | + | on_behalf_of: Principal { id: "usr_1".into(), username: "syntaqx".into() }, | |
| 510 | + | scope: g1t_contracts::identity::AgentScope { | |
| 511 | + | repo: RepoPath { namespace: "acme".into(), name: "rocket".into() }, | |
| 512 | + | operations: Vec::new(), | |
| 513 | + | run: None, | |
| 514 | + | }, | |
| 515 | + | })), | |
| 516 | + | ..person.clone() | |
| 517 | + | }; | |
| 518 | + | let by = By::from(&session); | |
| 519 | + | assert_eq!((by.id, by.name, by.acting_for), (G1T.0, "g1t", Some(ASKER))); | |
| 520 | + | let row: CommentRow = serde_json::from_value(serde_json::json!({ | |
| 521 | + | "id": "cmt_1", "kind": "event", "author_id": by.id, "author_name": by.name, | |
| 522 | + | "body": "closed this as completed", "path": null, "line": null, "verdict": null, | |
| 523 | + | "created_at": "", "acting_for_id": "usr_1", "acting_for_name": "syntaqx" | |
| 524 | + | })) | |
| 525 | + | .unwrap(); | |
| 526 | + | assert_eq!(row.answerable_id(), "usr_1"); | |
| 527 | + | let comment: Comment = row.into(); | |
| 528 | + | assert_eq!((comment.author.username.as_str(), comment.author.kind), ("g1t", PrincipalKind::Agent)); | |
| 529 | + | assert_eq!(comment.acting_for.map(|user| user.username).as_deref(), Some("syntaqx")); | |
| 530 | + | } | |
| 531 | + | ||
| 456 | 532 | #[test] | |
| 457 | 533 | fn g1t_s_own_work_reads_back_as_g1t_itself() { | |
| 458 | 534 | let own = pull(("g1t", "g1t"), None); | |
Comments
Sign in to comment.