Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

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
syntaqxcommitted Parent84f55cbBrowse files
15 files+383−850/15 viewed
+2−2
2222 | Access token | `g1t_…`, personal or a workspace's, with scopes | The API, MCP, git, the CLI | `user_for_access_token` |
2323 | OAuth access token | Issued by `POST /oauth/token`, refreshed with `g1r_…` | MCP clients, the phone app, apps you authorise | `user_for_access_token` |
2424 | 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` |
2626 | git password | Your account's password | git, only without two-factor authentication | `user_for_git_credentials` |
2727
2828 The CLI signs in with the device flow of RFC 8628 (`/device/code`, then
4141 | `kind` | `User`, `Workspace`, `Agent` or `System`: what kind of principal this is |
4242 | `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 |
4343 | `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 |
4545
4646 A service never asks `identity` whether someone may do something. Each
4747 calls `can` in `g1t_contracts::access`, the same rules compiled into every
+15−3
299299 g1t's MCP server is attached too, for anything the CLI has no command
300300 for. Its sandbox reaches the internet as a CI runner does, and it can
301301 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
305317 server's credential, and `G1T_TOKEN`, which the sandbox holds only as a
306318 placeholder that is swapped for the token on its way to g1t. It is never
307319 in the prompt, and the model never sees it. The session has g1t's skills
+8−3
262262 author,
263263 bot = false,
264264 agent,
265+ actingFor = agent?.actingFor,
265266 action,
266267 at,
267268 edited,
279280 * of `author`.
280281 */
281282 agent?: { ref: AgentRef; actingFor?: string | null };
283+ /** Whom g1t acted for, when a session g1t started for someone wrote it. */
284+ actingFor?: string | null;
282285 /** What they did, after their name: "commented", "opened this". */
283286 action: ReactNode;
284287 at?: string;
318321 )}
319322 {action}
320323 {at && <TimeAgo at={at} />}
321− {agent?.actingFor && (
324+ {actingFor && (
322325 <span className="text-xs text-faint">
323326 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}
326329 </PersonLink>
327330 </span>
328331 )}
644647 key={comment.id}
645648 id={comment.id}
646649 author={comment.author.username}
650+ bot={comment.author.kind === "agent"}
647651 agent={
648652 comment.agent && {
649653 ref: comment.agent,
650654 actingFor: comment.actingFor?.username,
651655 }
652656 }
657+ actingFor={comment.actingFor?.username}
653658 at={comment.createdAt}
654659 action={
655660 <>
+163−10
88 //! intersection of the two: the run's scope, and what that person may do
99 //! right now.
1010 //!
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+//!
1116 //! g1t's retired hosted agents had run credentials too (`implement`,
1217 //! `revise`, `review`, `answer`, `update`, `plan`). Those kinds still parse,
1318 //! so stored tokens and the history they wrote read as before, but no
5055 /// A security update: raising one package's version in its lockfiles
5156 /// and pushing that to a branch of its own. Not an agent.
5257 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,
5367 }
5468
5569 impl RunCredentialKind {
56− pub const ALL: [RunCredentialKind; 11] = [
70+ pub const ALL: [RunCredentialKind; 12] = [
5771 RunCredentialKind::Implement,
5872 RunCredentialKind::Revise,
5973 RunCredentialKind::Review,
6579 RunCredentialKind::Mergecheck,
6680 RunCredentialKind::Deploy,
6781 RunCredentialKind::Bump,
82+ RunCredentialKind::Session,
6883 ];
6984
7085 pub fn as_str(self) -> &'static str {
8095 RunCredentialKind::Mergecheck => "mergecheck",
8196 RunCredentialKind::Deploy => "deploy",
8297 RunCredentialKind::Bump => "bump",
98+ RunCredentialKind::Session => "session",
8399 }
84100 }
85101
105121 pub enum CredentialUse {
106122 /// g1t's runner: cloning, fetching and pushing what the run needs. It
107123 /// 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.
109126 Runner,
110127 /// A retired hosted agent's own tools, over MCP. Kept so stored tokens
111128 /// parse; never issued.
516533 READ_OPERATIONS.contains(&operation)
517534 }
518535
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+
519549 /// 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
522553 /// [retired](RunCredentialKind::retired) kind grants nothing. A stored
523554 /// token keeps the operations it was made with until it expires.
524555 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()
527572 }
528573
529574 /// What a run's credential may do, in the scope vocabulary that access
538583 .collect();
539584 if usage == CredentialUse::Runner && !kind.retired() {
540585 scopes.push(Scope::CodeRead);
541− if kind == RunCredentialKind::Bump {
586+ if matches!(kind, RunCredentialKind::Bump | RunCredentialKind::Session) {
542587 scopes.push(Scope::CodeWrite);
543588 }
544589 }
660705 /// Whether a run credential may clone or fetch (`write` false), or push to
661706 /// (`write` true), the repository at `repo`.
662707 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 {
663717 let Some(run) = scope
664718 .run
665719 .as_ref()
670724 "A tools token cannot be used with git.",
671725 );
672726 };
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));
674729 if write {
675730 return if pushable {
676731 Decision::allow(format!("{}:push", scope_rule(scope)))
795850 }
796851
797852 /// 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.
799858 pub fn as_person(user: &User) -> Option<User> {
800859 let acting = user.acting.as_ref()?;
801860 if user.kind != PrincipalKind::Agent {
813872 verified: user.verified,
814873 workspaces: user.workspaces.clone(),
815874 avatar: None,
816− acting: None,
875+ acting: (run.kind == RunCredentialKind::Session).then(|| acting.clone()),
817876 grants: user.grants.clone(),
818877 token: None,
819878 held: Vec::new(),
857916 let parsed: RunCredentialKind = serde_json::from_value(serde_json::json!(kind.as_str())).unwrap();
858917 assert_eq!(parsed, kind);
859918 for usage in [CredentialUse::Runner, CredentialUse::Tools] {
919+ if kind == RunCredentialKind::Session && usage == CredentialUse::Runner {
920+ continue;
921+ }
860922 assert!(operations_for(kind, usage).is_empty(), "{kind:?} {usage:?}");
861923 if kind.retired() {
862924 assert!(run_scopes(kind, usage).is_empty(), "{kind:?} {usage:?}");
12561318 assert!(back_run(&written).system);
12571319 }
12581320
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, &copy, Some(&rocket), true).allowed);
1388+ assert!(decide_git_at(&session, &copy, Some(&rocket), false).allowed);
1389+ assert_eq!(decide_git(&session, &copy, true).rule, "git:push");
1390+ assert_eq!(decide_git_at(&session, &copy, 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), &copy, 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+
12591412 fn back_run(written: &str) -> RunBinding {
12601413 serde_json::from_str::<AgentScope>(written).unwrap().run.unwrap()
12611414 }
+9−3
659659 /// Who is recorded as opening an issue or a pull request that `actor`
660660 /// opens, and who asked for it: `(author, requested_by)`.
661661 ///
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
663665 /// that still works: g1t, requested by that person.
664666 /// - Anyone else, and g1t's own work that nobody asked for: the actor, and
665667 /// 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.
666671 pub fn authorship(actor: &User) -> (User, Option<User>) {
667672 let person = |id: &str, username: &str| User {
668673 id: id.to_owned(),
670675 kind: crate::PrincipalKind::User,
671676 ..User::default()
672677 };
673− if actor.kind == crate::PrincipalKind::Agent {
678+ if actor.kind == crate::PrincipalKind::Agent || actor.acting.is_some() {
674679 let asked = actor
675680 .acting
676681 .as_ref()
904909 #[serde(default, skip_serializing_if = "Option::is_none")]
905910 pub agent: Option<AgentRef>,
906911 /// 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.
908914 #[serde(default, skip_serializing_if = "Option::is_none")]
909915 pub acting_for: Option<User>,
910916 /// An agent's review: shown with its verdict (or none, for a review
+6−3
2424 | "queue"
2525 | "mergecheck"
2626 | "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";
2830
2931 /**
3032 * `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.
3336 */
3437 export type CredentialUse = "runner" | "tools";
3538
+5−1
333333 * show it by `agent.displayName` with an Agent badge, never as a person.
334334 */
335335 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+ */
337341 actingFor?: User;
338342 /**
339343 * An agent's review. Its `verdict` (none for a review that only
+23−3
11 //! 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
34 //! run, its repository and what its kind of work needs, acting as g1t on
45 //! behalf of the person who started the work, or of the workspace for
56 //! g1t's own. See `g1t_contracts::credentials` for the policy.
2425
2526 /// No run outlives this, whatever its caller asks for.
2627 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;
2731 const MIN_RUN_TTL_SECONDS: u64 = 60;
2832 /// More hashes than one sandbox ever holds.
2933 const MAX_HASHES: usize = 8;
3539 !kind.retired() && usage == CredentialUse::Runner
3640 }
3741
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+
3850 /// A SHA-256 in lowercase hex, as tokens are stored.
3951 fn is_hash(value: &str) -> bool {
4052 value.len() == 64
164176 ),
165177 ttl_seconds: Some(
166178 a.ttl_seconds
167− .clamp(MIN_RUN_TTL_SECONDS, MAX_RUN_TTL_SECONDS),
179+ .clamp(MIN_RUN_TTL_SECONDS, max_ttl(a.kind)),
168180 ),
169181 // A run credential's scope is its run's; see agent_scope.
170182 scopes: None,
261273 .filter(|kind| issued_for(**kind, CredentialUse::Runner))
262274 .map(|kind| kind.as_str())
263275 .collect();
264− assert_eq!(issued, ["checks", "queue", "mergecheck", "deploy", "bump"]);
276+ assert_eq!(issued, ["checks", "queue", "mergecheck", "deploy", "bump", "session"]);
265277 // A retired hosted agent's tools token is never made.
266278 assert!(RunCredentialKind::ALL.iter().all(|kind| !issued_for(*kind, CredentialUse::Tools)));
267279 }
268280
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+
269289 #[test]
270290 fn only_hashes_are_taken() {
271291 assert!(is_hash(&"a1".repeat(32)));
+24−26
44 //! g1t runs no model. With the workspace's Anthropic connection, g1t starts
55 //! a Claude Managed Agents session with that key: it keeps one agent and one
66 //! 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
1013 //! the CLI (`environment_variable`, swapped in on the way out). The model and
1114 //! the sandbox never see the token, and it is never in the prompt; the key
1215 //! never leaves this service. g1t's own skills (work-on-issue, coordinate,
3235 use std::collections::BTreeMap;
3336
3437 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};
3640 use g1t_contracts::ai::AiProvider;
3741 use g1t_contracts::repos::RepoPath;
3842 use g1t_contracts::time::{parse_rfc3339, rfc3339};
6569 const TOKEN_TTL_SECONDS: u64 = 12 * 60 * 60;
6670 /// A session still going after this long is stopped.
6771 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−];
7972 /// The most events read from one stream in one look.
8073 const PAGE: u32 = 100;
8174 const MAX_PAGES: usize = 5;
109102 - Use the g1t CLI from the clone, as the work-on-issue skill does: `g1t work context N`, `g1t work claim N --note …`, \
110103 `g1t pr create --issue N --draft --fill`, `g1t pr checks --watch --fail-fast`, `g1t run logs --failed`. \
111104 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\
113107 - `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, \
114108 never in a URL. Never print it or send it anywhere else.\n\
115109 - 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. \
123117 `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\
124118 - Never write `@g1t` in what you post on g1t: that asks g1t for another session.\n\
125119 - 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."
127121 .to_owned()
128122 }
129123
10401034 Some((number, title, _)) => format!("#{number} {}", title.trim()),
10411035 None => format!("#{} {}", issue.number, issue.title.trim()),
10421036 };
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.
10441040 let created: CreatedAccessToken = g1t_kit::call(
10451041 &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,
10541052 },
10551053 )
10561054 .await?;
+14−3
22 //!
33 //! A sandbox's runner clones, fetches and pushes with a credential bound to
44 //! 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
67 //! as the person it works for once let through, so the repository's own
78 //! rules then apply to them too. Every git request a run credential makes
89 //! is recorded, and so is every push, by anyone.
910
1011 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};
1213 use g1t_contracts::repos::{GitService, Repo, RepoPath};
1314 use g1t_contracts::{PrincipalKind, Viewer};
1415 use worker::js_sys::Uint8Array;
213214 });
214215 };
215216
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);
217228 let mut refs = Vec::new();
218229 if decision.allowed && post && write && limits_branches(&scope, &git.path) {
219230 let body = request.bytes().await?;
+1−1
111111 return Ok(());
112112 }
113113 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;
115115 if !added.is_empty() {
116116 self.note(&issue.repo_id, issue.number, who, &format!("marked this as blocked by {}", list(&added))).await?;
117117 }
+1−1
472472 removed: &[String],
473473 colors: &[(String, String)],
474474 ) -> Result<()> {
475− let who = (actor.id.as_str(), actor.username.as_str());
475+ let who = actor;
476476 if !added.is_empty() {
477477 self.note(item.repo_id(), item.number(), who, &label_note("added", added)).await?;
478478 }
+34−24
400400
401401 /// Records something that happened to an issue or a pull request, so
402402 /// 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>(
405406 &self,
406407 repo_id: &str,
407408 number: u32,
408− author: (&str, &str),
409+ author: impl Into<rows::By<'a>>,
409410 text: &str,
410411 ) -> Result<()> {
412+ let author = author.into();
411413 let now = now_ms();
412414 self.db
413415 .prepare(
414416 "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', ?, ?, ?)",
417420 )
418421 .bind(&[
419422 new_id("cmt", now).into(),
420423 repo_id.into(),
421424 number.into(),
422− author.0.into(),
423− author.1.into(),
425+ author.id.into(),
426+ author.name.into(),
424427 text.into(),
425428 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()),
426431 ])?
427432 .run()
428433 .await?;
449454 };
450455 let new: Vec<&String> = after.iter().filter(|name| !before.contains(name)).collect();
451456 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;
453458 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() {
456462 "self-assigned this".to_owned()
457463 } else {
458464 format!("{added} {}", joined(new))
928934 self.note(
929935 &issue.repo_id,
930936 issue.number,
931− (&a.actor.id, &a.actor.username),
937+ &a.actor,
932938 &pushed::closed_note(note, session.as_deref()),
933939 )
934940 .await?;
970976 self.note(
971977 &issue.repo_id,
972978 issue.number,
973− (&a.actor.id, &a.actor.username),
979+ &a.actor,
974980 "reopened this",
975981 )
976982 .await?;
10621068 };
10631069
10641070 let now = now_ms();
1071+ // A session g1t started for someone writes as g1t, for them.
1072+ let by = rows::By::from(&a.actor);
10651073 let comment = Comment {
10661074 kind: CommentKind::Comment,
10671075 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()) },
10691077 body: body.to_owned(),
10701078 path: path.map(str::to_owned),
10711079 line,
10731081 created_at: rfc3339(now),
10741082 edited_at: None,
10751083 agent: None,
1076− acting_for: None,
1084+ acting_for: by.acting_for.map(|(id, name)| rows::user(id.to_owned(), name.to_owned())),
10771085 advisory: false,
10781086 reactions: Vec::new(),
10791087 };
10831091 .prepare(
10841092 "INSERT INTO comments
10851093 (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
10881096 )
10891097 .bind(&[
10901098 comment.id.as_str().into(),
10911099 repo.id.as_str().into(),
10921100 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(),
10951103 body.into(),
10961104 optional(&comment.path),
10971105 optional_number(line),
10981106 a.verdict
10991107 .map_or(JsValue::NULL, |verdict| verdict.as_str().into()),
11001108 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()),
11011111 ])?,
11021112 self.db
11031113 .prepare(format!(
14591469 fill_base(&mut pull, &repo);
14601470 if let Some(issue) = pull.issue {
14611471 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)
14631473 .await?;
14641474 }
14651475 // Its head and merge refs in the repository, before its events go out.
19661976 self.note(
19671977 &pull.repo_id,
19681978 pull.number,
1969− (&actor.id, &actor.username),
1979+ actor,
19701980 &format!("changed the base branch from `{before}` to `{base}`"),
19711981 )
19721982 .await?;
20362046 self.note(
20372047 &pull.repo_id,
20382048 pull.number,
2039− (&a.actor.id, &a.actor.username),
2049+ &a.actor,
20402050 "marked this ready for review",
20412051 )
20422052 .await?;
20782088 self.note(
20792089 &pull.repo_id,
20802090 pull.number,
2081− (&a.actor.id, &a.actor.username),
2091+ &a.actor,
20822092 "closed this",
20832093 )
20842094 .await?;
21492159 self.note(
21502160 &pull.repo_id,
21512161 pull.number,
2152− (&a.actor.id, &a.actor.username),
2162+ &a.actor,
21532163 "reopened this",
21542164 )
21552165 .await?;
21972207 self.note(
21982208 &pull.repo_id,
21992209 pull.number,
2200− (&a.actor.id, &a.actor.username),
2210+ &a.actor,
22012211 "marked this as a draft",
22022212 )
22032213 .await?;
+1−1
336336 .run()
337337 .await?;
338338 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?;
340340 }
341341 for (what, milestone) in [("demilestoned", before), ("milestoned", after)] {
342342 let Some(milestone) = milestone else { continue };
+77−1
3333 Some(user(id?, username?))
3434 }
3535
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+
3674 #[derive(Deserialize)]
3775 pub struct IssueRow {
3876 pub id: String,
324362 _ => None,
325363 };
326364 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;
327368 let mut author = user(row.author_id, row.author_name);
328369 if agent.is_some() {
329370 author.kind = g1t_contracts::PrincipalKind::Agent;
339380 verdict: row.verdict.or(advisory_verdict.and_then(AgentVerdict::verdict)),
340381 created_at: row.created_at,
341382 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 },
343384 advisory: agent.is_some() && advisory_verdict.is_some(),
344385 agent,
345386 reactions: Vec::new(),
453494 assert!(requester(Some("usr_1".into()), None).is_none());
454495 }
455496
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+
456532 #[test]
457533 fn g1t_s_own_work_reads_back_as_g1t_itself() {
458534 let own = pull(("g1t", "g1t"), None);

Comments

Sign in to comment.