ac-teams 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/docs/access.md +103 -0
- package/docs/design/session-roles/README.md +343 -0
- package/docs/design/session-roles/coordinator.yaml +44 -0
- package/docs/design/session-roles/design.yaml +70 -0
- package/docs/design/session-roles/herdr.yaml +17 -0
- package/docs/design/session-roles/journey.md +139 -0
- package/docs/design/session-roles/main-session.yaml +34 -0
- package/docs/design/session-roles/member.yaml +28 -0
- package/docs/design/session-roles/read-guard.yaml +17 -0
- package/docs/design/session-roles/team-store.yaml +13 -0
- package/docs/design/session-roles/transcripts.yaml +23 -0
- package/docs/design/session-roles/user.yaml +17 -0
- package/docs/development.md +18 -0
- package/docs/operations.md +18 -7
- package/docs/usage.md +14 -3
- package/package.json +1 -1
- package/src/access.ts +175 -0
- package/src/commands.ts +1 -1
- package/src/config.ts +4 -2
- package/src/coordinator.ts +472 -38
- package/src/egress.ts +187 -0
- package/src/index.ts +253 -58
- package/src/mains.ts +41 -0
- package/src/panel.ts +9 -1
- package/src/sandbox-bridge.mjs +33 -0
- package/src/sandbox.Dockerfile +8 -0
- package/src/schemas.ts +10 -2
- package/src/store.ts +38 -11
- package/src/terminal-agent.ts +1 -1
- package/src/terminal-ui.ts +6 -3
- package/src/types.ts +24 -4
- package/src/ui-types.ts +4 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
## Session roles: promotion, joining, and several main sessions
|
|
2
|
+
|
|
3
|
+
Status: draft for review. Implements nothing yet. Resolves the design questions in [issue #19](https://github.com/ac8318740/ac-teams/issues/19).
|
|
4
|
+
|
|
5
|
+
A team can have several **main sessions**. A main session is a Pi session the user created, joined to a team, or promoted. Every other team process is a **member**. The user can promote a member to a main session from the TUI, or join an existing session to a team with `/teams join`. ac-teams marks every member process with `ACSTACK_AGENT_ROLE=member`, so ACStack's large-read guard applies only to main sessions.
|
|
6
|
+
|
|
7
|
+
### Review questions
|
|
8
|
+
|
|
9
|
+
1. Does promotion by relaunch feel seamless enough, given that a promoted session moves to its own Herdr tab?
|
|
10
|
+
2. Does it look right that every main session can control every member and other main sessions? That matches what an unsandboxed session can already do through its shell.
|
|
11
|
+
3. Is the takeover after the owner exits (automatic, with a notice) the right default?
|
|
12
|
+
4. Are the changes and tests at the end complete enough to start implementation?
|
|
13
|
+
|
|
14
|
+
### Trade-offs to judge
|
|
15
|
+
|
|
16
|
+
- **Relaunch, not live handover.** Promotion stops the member process and reopens its transcript in a new process. The member loses its in-flight turn, but its role, sandbox, token, and environment change cleanly.
|
|
17
|
+
- **No coordinator daemon.** The coordinator stays inside whichever main session holds `owner.lock`. When the owner exits, members stop and the next owner resumes them automatically. The cost is a pause and at most one redone turn per busy member.
|
|
18
|
+
- **Wide main-session authority.** Any main session can stop work started by another. ac-teams does not try to guard against this, because an unsandboxed session can already stop processes through the shell or Herdr.
|
|
19
|
+
|
|
20
|
+
## Definitions
|
|
21
|
+
|
|
22
|
+
These five properties are independent. A change to one never changes another.
|
|
23
|
+
|
|
24
|
+
| Property | Defined by | Changes when |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| Identity | Pi session id in the transcript header | Never |
|
|
27
|
+
| Discovery | Where the transcript file lives | Promotion moves it to the top-level session folder |
|
|
28
|
+
| Membership | A member record in the team store | Join adds one; leave clears its role |
|
|
29
|
+
| Coordinator ownership | Holder of `owner.lock` | The owner exits and another main session takes it |
|
|
30
|
+
| Authority | The record's `role`: `main` or `member` | Only promotion, join, or leave, all user actions |
|
|
31
|
+
|
|
32
|
+
### What the coordinator does
|
|
33
|
+
|
|
34
|
+
The coordinator is ac-teams' in-process server. Today it runs inside Main. In this design, it runs inside whichever main session holds `owner.lock`. In practice it:
|
|
35
|
+
|
|
36
|
+
- **Runs member processes.** Starts each member's Pi, either headless or in tmux, optionally inside a Docker sandbox. It also stops and resumes members, and stops them all when its host session exits
|
|
37
|
+
- **Answers team tools.** Every `team_agent`, `team_task`, and `team_message` call from any session arrives over `coordinator.sock` and is applied here, so concurrent edits are serialized
|
|
38
|
+
- **Keeps team state.** Writes members, tasks, and messages to the team store
|
|
39
|
+
- **Delivers notifications.** Tells each session when new team messages arrive
|
|
40
|
+
- **Enforces rules.** Checks authority, depth and member limits, and sandbox grants, and brokers provider credentials for sandboxed members
|
|
41
|
+
|
|
42
|
+
This design keeps all of that. It adds role checks, connections from main sessions it did not launch, promotion, joining, leaving, and ownership takeover.
|
|
43
|
+
|
|
44
|
+
The team id stays the session id of the session that created the team, even after that session leaves or hands over ownership.
|
|
45
|
+
|
|
46
|
+
### Main-agent signal
|
|
47
|
+
|
|
48
|
+
- The coordinator sets `ACSTACK_AGENT_ROLE=member` on every member process, next to `AC_TEAMS_MEMBER`. That includes the terminal-launch script, which today only passes `AC_TEAMS_*` and `HERDR_ENV`
|
|
49
|
+
- A hook treats the session as main when the variable is absent or `main`, and as not main for any other value
|
|
50
|
+
- Anything a member starts inherits the variable and counts as not main
|
|
51
|
+
- Promotion and every main-session launch must remove `ACSTACK_AGENT_ROLE`, every `AC_TEAMS_*` variable, and `HERDR_ENV=0`. Tests must cover this
|
|
52
|
+
- ACStack reads the variable on every tool call. It detects Claude Code and Codex subagents from `agent_id` in their hook payloads instead. That detection belongs to ACStack, not ac-teams
|
|
53
|
+
- Role grants no permission by itself
|
|
54
|
+
|
|
55
|
+
## User flows
|
|
56
|
+
|
|
57
|
+
### Promote a member
|
|
58
|
+
|
|
59
|
+
1. The user opens a teammate from the agent selector or `/teams` and chooses **Promote to main**
|
|
60
|
+
2. If the member is sandboxed, a confirmation states that the session will run with the user's full access. Declining cancels
|
|
61
|
+
3. The coordinator stops the member, waiting for its process to exit as `stop` does today
|
|
62
|
+
4. The coordinator moves the transcript from `ac-teams/<team>/<member>/` to the project's top-level session folder. It removes the member's config at `~/.pi/ac-teams/agents/<id>` and sets the member's role to `main` without a parent. The member's children stay its descendants
|
|
63
|
+
5. The coordinator writes the session's team entry: a Pi custom entry holding the team id and member id, with no secrets
|
|
64
|
+
6. The coordinator relaunches `pi --session <moved file>` with a clean environment:
|
|
65
|
+
- With Herdr, in a new tab in the workspace for the session's worktree, creating the workspace if needed. The tab gets focus and the session's name
|
|
66
|
+
- Without Herdr, in a tmux session on a per-main-session socket that the owner's `kill-server` cannot reach. The user's screen stays attached, and ac-teams prints the reattach command
|
|
67
|
+
7. The new process reads its team entry, connects to the coordinator with a main-session token from the team store, and appears in `/resume`
|
|
68
|
+
|
|
69
|
+
### Join an existing session to a team
|
|
70
|
+
|
|
71
|
+
1. The user runs `/teams join` in an ordinary Pi session
|
|
72
|
+
2. ac-teams lists this project's teams. The user picks one
|
|
73
|
+
3. ac-teams refuses if the session already belongs to a team with members, or if its session id is already in the chosen team
|
|
74
|
+
4. The coordinator adds a member record with role `main` and writes the team entry
|
|
75
|
+
5. The session receives a short summary of open tasks, owners, and recent messages, with its message cursor at the latest message. It claims or creates work with the normal team tools
|
|
76
|
+
|
|
77
|
+
### Several main sessions
|
|
78
|
+
|
|
79
|
+
- Each main session holds one connection to the coordinator. The owner hosts it in-process; the others connect over `coordinator.sock`
|
|
80
|
+
- Every main session sits at depth 0. Any main session can launch, stop, resume, and clean up any member, and stop or resume another main session
|
|
81
|
+
- Stopping a main session asks it to shut down through its connection. Resuming one relaunches its transcript, as in step 6 of promotion
|
|
82
|
+
- Members keep today's rule: they control only their descendants and never a main session
|
|
83
|
+
- Tasks and messages stay shared. Ownership and revision checks still serialize concurrent edits, because one coordinator applies them all
|
|
84
|
+
|
|
85
|
+
### Leave a team
|
|
86
|
+
|
|
87
|
+
`/teams leave` returns the session's in-progress tasks to todo and clears their ownership. The member record keeps its history but loses its role, so its descendants stay under the other main sessions. The team entry records the departure, so a later `/resume` does not rejoin. Leaving is refused while the session is the only main session and members are still live.
|
|
88
|
+
|
|
89
|
+
## Recovery
|
|
90
|
+
|
|
91
|
+
- **The owner exits while another main session runs.** Before stopping members, the owner records each one's state (busy, idle, or needs input) in the team store. Today, `Coordinator.open` discards that state. A connected main session then takes `owner.lock`, opens the coordinator, and runs the resume pass below. Every main session gets a notice naming the new owner
|
|
92
|
+
- **No main session is running when the owner exits.** Members stay stopped with their recorded state. The next main session that opens the team runs the same resume pass
|
|
93
|
+
- **A promoted session is resumed with no coordinator running.** The extension reads its team entry at startup and offers to take ownership. Accepting opens the coordinator and runs the resume pass. Declining runs the session without team tools
|
|
94
|
+
|
|
95
|
+
### Resume pass
|
|
96
|
+
|
|
97
|
+
| Recorded state | Action |
|
|
98
|
+
|---|---|
|
|
99
|
+
| Busy | Resume and send: "Your process was restarted during a coordinator handover. Check your last steps and continue your current task." |
|
|
100
|
+
| Idle | Resume and leave idle |
|
|
101
|
+
| Needs input | Resume and show **Needs input** again |
|
|
102
|
+
|
|
103
|
+
Pi saves each completed message, so a busy member loses only the reply it was generating and redoes that turn. A command killed mid-run may have half finished. The message tells the member to check before it continues, which reduces that risk but does not remove it, the same as stopping a member today.
|
|
104
|
+
- **A main session crashes.** Its record stays. The coordinator marks it interrupted, and any main session can resume it
|
|
105
|
+
- **Promotion fails partway.** Each step is recorded in the team store before the next starts. On failure, the coordinator rolls back to a stopped member with its original transcript path, and the member can be retried or resumed
|
|
106
|
+
|
|
107
|
+
## Security boundaries
|
|
108
|
+
|
|
109
|
+
- Only the user can promote, join, or leave. These actions have no agent-tool form
|
|
110
|
+
- Promoting a sandboxed member is an explicit escalation, approved in the confirmation step. Without approval, nothing changes
|
|
111
|
+
- Main-session tokens live in the private team store, which sandboxed members cannot read. A member therefore cannot impersonate a main session over the socket
|
|
112
|
+
- Descendant non-escalation stays as it is: a member cannot grant its descendants more access than it has
|
|
113
|
+
- Provider credentials for sandboxed members come from the current owner's Pi login. After a takeover, the new owner brokers them under the same OS user
|
|
114
|
+
|
|
115
|
+
## Changes needed before implementation
|
|
116
|
+
|
|
117
|
+
- **Team store schema 4.** Add `role` to members and migrate `root` to a main record. Replace `actor === "root"` checks with role checks. Store each main session's token
|
|
118
|
+
- **Coordinator.**
|
|
119
|
+
- Accept connections from main sessions that were not launched by the coordinator
|
|
120
|
+
- Add promote, join, leave, and the stop and resume of main sessions
|
|
121
|
+
- Add ownership takeover, and recompute descendant depths after a promotion
|
|
122
|
+
- **Launch environment.** Set `ACSTACK_AGENT_ROLE=member` for every member, including the terminal script's variable filter. Strip it on main-session launches
|
|
123
|
+
- **Extension.** Read the team entry at `session_start`. Add `/teams join`, `/teams leave`, and the **Promote to main** action. Open Herdr tabs, or use the tmux fallback
|
|
124
|
+
- **Docs.** Update `docs/usage.md`, `docs/operations.md`, and `docs/access.md`
|
|
125
|
+
|
|
126
|
+
## Tests needed
|
|
127
|
+
|
|
128
|
+
- A launched member has `ACSTACK_AGENT_ROLE=member`, in both RPC and terminal modes
|
|
129
|
+
- A promoted session's environment has no `ACSTACK_AGENT_ROLE` and no `AC_TEAMS_*` variables
|
|
130
|
+
- Promotion keeps the session id, moves the file, updates the stored path, and writes the team entry. Pi's session listing then includes the session
|
|
131
|
+
- A sandboxed promotion is refused without confirmation and runs unsandboxed after it
|
|
132
|
+
- Joining refuses a duplicate session id, and refuses a session that leads another team with members
|
|
133
|
+
- A main session can stop and resume members and other main sessions. A member cannot act on a main session
|
|
134
|
+
- When the owner exits, another main session takes the lock. Busy members resume with the continue message, idle members stay idle, and members that needed input show **Needs input** again
|
|
135
|
+
- When no main session is running, the next one to open the team runs the same resume pass
|
|
136
|
+
- Resuming a promoted transcript with no coordinator offers takeover, and declining leaves team tools off
|
|
137
|
+
- Leaving returns in-progress tasks to todo and keeps descendants manageable
|
|
138
|
+
- A promotion interrupted after each step rolls back to a resumable member
|
|
139
|
+
- Schema 3 teams migrate to schema 4 with root as a main record
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
id: main-session
|
|
2
|
+
name: Main session
|
|
3
|
+
summary: >-
|
|
4
|
+
A Pi session the user started, joined to a team, or promoted. Every main
|
|
5
|
+
session controls every member and other main sessions, shares the team's
|
|
6
|
+
tasks and messages, and belongs to one team at a time.
|
|
7
|
+
kind: component
|
|
8
|
+
channels: [cli]
|
|
9
|
+
relationships:
|
|
10
|
+
- id: user-drives-main
|
|
11
|
+
from: user
|
|
12
|
+
to: main-session
|
|
13
|
+
direction: one-way
|
|
14
|
+
label: Promotes, joins, and leaves through /teams in
|
|
15
|
+
- id: main-uses-coordinator
|
|
16
|
+
from: main-session
|
|
17
|
+
to: coordinator
|
|
18
|
+
direction: one-way
|
|
19
|
+
label: Hosts as owner, or connects to as a client
|
|
20
|
+
- id: main-writes-transcript
|
|
21
|
+
from: main-session
|
|
22
|
+
to: transcripts
|
|
23
|
+
direction: one-way
|
|
24
|
+
label: Writes its conversation and team entry to
|
|
25
|
+
- id: guard-reads-main
|
|
26
|
+
from: read-guard
|
|
27
|
+
to: main-session
|
|
28
|
+
direction: one-way
|
|
29
|
+
label: Finds no member role in
|
|
30
|
+
- id: coordinator-launches-main
|
|
31
|
+
from: coordinator
|
|
32
|
+
to: main-session
|
|
33
|
+
direction: one-way
|
|
34
|
+
label: Relaunches promoted members as
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
id: member
|
|
2
|
+
name: Member
|
|
3
|
+
summary: >-
|
|
4
|
+
An agent-launched Pi process with ACSTACK_AGENT_ROLE=member and AC_TEAMS_*
|
|
5
|
+
variables. It controls only its descendants, and its transcript stays under
|
|
6
|
+
ac-teams/<team>/<member>/ until promotion.
|
|
7
|
+
kind: component
|
|
8
|
+
relationships:
|
|
9
|
+
- id: coordinator-runs-member
|
|
10
|
+
from: coordinator
|
|
11
|
+
to: member
|
|
12
|
+
direction: one-way
|
|
13
|
+
label: Launches, stops, and resumes
|
|
14
|
+
- id: member-uses-coordinator
|
|
15
|
+
from: member
|
|
16
|
+
to: coordinator
|
|
17
|
+
direction: one-way
|
|
18
|
+
label: Calls team tools through
|
|
19
|
+
- id: member-writes-transcript
|
|
20
|
+
from: member
|
|
21
|
+
to: transcripts
|
|
22
|
+
direction: one-way
|
|
23
|
+
label: Writes its conversation to
|
|
24
|
+
- id: guard-reads-member
|
|
25
|
+
from: read-guard
|
|
26
|
+
to: member
|
|
27
|
+
direction: one-way
|
|
28
|
+
label: Reads ACSTACK_AGENT_ROLE=member in
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
id: read-guard
|
|
2
|
+
name: ACStack read guard
|
|
3
|
+
summary: >-
|
|
4
|
+
ACStack's large-read hook. It blocks a large untargeted read once in main
|
|
5
|
+
agents only, and reads ACSTACK_AGENT_ROLE on every call to decide.
|
|
6
|
+
kind: external-system
|
|
7
|
+
relationships:
|
|
8
|
+
- id: guard-reads-member
|
|
9
|
+
from: read-guard
|
|
10
|
+
to: member
|
|
11
|
+
direction: one-way
|
|
12
|
+
label: Reads ACSTACK_AGENT_ROLE=member in
|
|
13
|
+
- id: guard-reads-main
|
|
14
|
+
from: read-guard
|
|
15
|
+
to: main-session
|
|
16
|
+
direction: one-way
|
|
17
|
+
label: Finds no member role in
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
id: team-store
|
|
2
|
+
name: Team store
|
|
3
|
+
summary: >-
|
|
4
|
+
Private team state under ~/.pi/ac-teams/teams/<team>/: members with their
|
|
5
|
+
role, tasks, messages, owner.lock, and each main session's connection
|
|
6
|
+
token. Sandboxed members cannot read it.
|
|
7
|
+
kind: store
|
|
8
|
+
relationships:
|
|
9
|
+
- id: coordinator-writes-store
|
|
10
|
+
from: coordinator
|
|
11
|
+
to: team-store
|
|
12
|
+
direction: one-way
|
|
13
|
+
label: Holds owner.lock and records roles in
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
id: transcripts
|
|
2
|
+
name: Pi transcripts
|
|
3
|
+
summary: >-
|
|
4
|
+
Standard Pi session files. Main sessions sit at the top level of the
|
|
5
|
+
project's session folder, where /resume finds them. Members sit under
|
|
6
|
+
ac-teams/<team>/<member>/, where only /teams finds them.
|
|
7
|
+
kind: store
|
|
8
|
+
relationships:
|
|
9
|
+
- id: main-writes-transcript
|
|
10
|
+
from: main-session
|
|
11
|
+
to: transcripts
|
|
12
|
+
direction: one-way
|
|
13
|
+
label: Writes its conversation and team entry to
|
|
14
|
+
- id: member-writes-transcript
|
|
15
|
+
from: member
|
|
16
|
+
to: transcripts
|
|
17
|
+
direction: one-way
|
|
18
|
+
label: Writes its conversation to
|
|
19
|
+
- id: coordinator-moves-transcript
|
|
20
|
+
from: coordinator
|
|
21
|
+
to: transcripts
|
|
22
|
+
direction: one-way
|
|
23
|
+
label: Moves promoted transcripts within
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
id: user
|
|
2
|
+
name: User
|
|
3
|
+
summary: >-
|
|
4
|
+
Starts main sessions and is the only party who can promote a member, join a
|
|
5
|
+
session to a team, or approve a sandboxed member's full access.
|
|
6
|
+
kind: actor
|
|
7
|
+
relationships:
|
|
8
|
+
- id: user-drives-main
|
|
9
|
+
from: user
|
|
10
|
+
to: main-session
|
|
11
|
+
direction: one-way
|
|
12
|
+
label: Promotes, joins, and leaves through /teams in
|
|
13
|
+
- id: user-uses-herdr
|
|
14
|
+
from: user
|
|
15
|
+
to: herdr
|
|
16
|
+
direction: one-way
|
|
17
|
+
label: Switches between main sessions in
|
package/docs/development.md
CHANGED
|
@@ -48,6 +48,24 @@ Terminal UI checks used real native Pi processes in an isolated full-size Herdr
|
|
|
48
48
|
- Task summary above the input, agent selector below status, native subcommand/name autocomplete, and same-PID/session opening by name or ID
|
|
49
49
|
- Pi-reported model display names and changing context estimates in the live selector
|
|
50
50
|
|
|
51
|
+
### Enforced access verification
|
|
52
|
+
|
|
53
|
+
Checks on 2026-11-02, Ubuntu 24.04, kernel 6.8, Docker 29.9.2 (rootful, default AppArmor and seccomp), Pi 1.4.0, member image `ac-teams-member:1.4.0-<hash>`:
|
|
54
|
+
|
|
55
|
+
- `test/sandbox.test.ts` with real containers for RPC and native (tmux) members: grants enforced read-only and read-write; host files, other transcripts, team state, the real `auth.json`, the Docker socket, and the tmux socket unreachable; only the selected provider's access token is present, without a refresh token, and a token change reaches the container in place; public HTTP and HTTPS work through the proxy with curl and Node; this host's addresses (including its public IP and Tailscale address), the Docker bridge, and cloud metadata are refused; no direct network, UDP, or host Unix socket; the start gate cannot be replayed; no capabilities, `NoNewPrivs`, private PIDs, read-only root, written files owned by the user; nested narrower launch works and escalation is refused; the native member's terminal is the container's own pty, and TIOCSTI did not inject; containers and member credentials are removed on close
|
|
56
|
+
- A grant swapped for a symlink between the checks and start is caught before any member code runs, and the container is removed; a writer of a repo and a reader of its subdirectory run together; resume gets a new gated container
|
|
57
|
+
- A refused second owner leaves live containers alone; after the coordinator is killed, the next owner removes the orphaned container; the enforced-member cap counts other teams' containers
|
|
58
|
+
- A real OpenAI OAuth member with the full installed extension set (billion-context active, ACStack, pi-web-access) answered through the proxy, ran curl (200), `web_search`, and `fetch_content` on a public documentation page; a member with `network.allow: []` still reached the model while curl to a public site failed; no refresh token or other provider in the container; network `none`, no ports, and no host listener changes
|
|
59
|
+
|
|
60
|
+
Anthropic, later on 2026-11-02 (Pi 1.4.0 for the first two checks; Pi 1.5.0 with member image `ac-teams-member:1.5.0-<hash>` for the third and for a full `npm test` rerun, 118 pass):
|
|
61
|
+
|
|
62
|
+
- A `claude-haiku-4-5` member with the full installed extension set ran curl (200), `web_search`, and `fetch_content`; with `network.allow: []` it still reached the model while curl failed; no refresh token or other provider in the container; network `none`, no ports
|
|
63
|
+
- A real Pi Main loading this branch from an isolated agent directory (access tokens only, no refresh tokens) launched an enforced Anthropic member with `network.allow: []` through Main's own credential code: the sandbox was available, the launch succeeded, and the member answered `curl=000`; no host listener changes and no leftover containers or member files
|
|
64
|
+
|
|
65
|
+
On the final tree with Pi 1.5.0, the OpenAI full-extension and `network.allow: []` runs passed again: curl 200, `web_search` and `fetch_content` ok, then curl `000` with the model still reachable. `npm test` passed 119 tests, and the Docker suite passed 10.
|
|
66
|
+
|
|
67
|
+
Not verified: an Anthropic Main session, because Main's own Anthropic request was refused as out of extra usage while the member's Haiku requests succeeded.
|
|
68
|
+
|
|
51
69
|
Not verified on macOS, Windows, or network filesystems. The package is Linux-only because safe process identity uses `/proc`. No web dashboard, workflow DSL, daemon, or automatic merging is included.
|
|
52
70
|
|
|
53
71
|
## Stage releases for npm approval
|
package/docs/operations.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Operate ac-teams
|
|
2
2
|
|
|
3
|
-
ac-teams runs on Linux with Node 22.19 or newer and an authenticated `pi` executable on `PATH`. Interactive roots also require `tmux`: each team owns a private server and native member terminals. Headless roots retain RPC members. Both share your OS user's access
|
|
3
|
+
ac-teams runs on Linux with Node 22.19 or newer and an authenticated `pi` executable on `PATH`. Interactive roots also require `tmux`: each team owns a private server and native member terminals. Headless roots retain RPC members. Both share your OS user's access unless a member is launched with [enforced access](access.md).
|
|
4
4
|
|
|
5
5
|
## Save project trust before launching
|
|
6
6
|
|
|
@@ -15,7 +15,7 @@ Default: at most **10 live descendants**, counting starting, busy, idle, and sto
|
|
|
15
15
|
Root policy precedence, from lowest to highest:
|
|
16
16
|
|
|
17
17
|
1. Built-in defaults
|
|
18
|
-
2. `~/.
|
|
18
|
+
2. `~/.pi/ac-teams/config.json`
|
|
19
19
|
3. `.pi/ac-teams.json`, only with `--teams-project-config`
|
|
20
20
|
4. Explicit `--teams-limit` and `--teams-depth` flags
|
|
21
21
|
|
|
@@ -33,7 +33,7 @@ Other delegation tools remain outside these limits. See [companion tools](usage.
|
|
|
33
33
|
|
|
34
34
|
## Display preferences are global
|
|
35
35
|
|
|
36
|
-
`/teams config` saves task-summary and agent-selector visibility to `~/.
|
|
36
|
+
`/teams config` saves task-summary and agent-selector visibility to `~/.pi/ac-teams/display.json`:
|
|
37
37
|
|
|
38
38
|
```json
|
|
39
39
|
{"tasks":true,"agents":true}
|
|
@@ -51,6 +51,8 @@ This is snapshot-based cleanup, not OS containment. Detached or uncaptured desce
|
|
|
51
51
|
|
|
52
52
|
The interactive panel can instead quietly resume a saved member session without inventing a new brief. Feedback then arrives through the durable team mailbox.
|
|
53
53
|
|
|
54
|
+
Stopping a main session ends its Pi process; an exiting coordinator hands over first. Resuming another main session relaunches it in its own terminal.
|
|
55
|
+
|
|
54
56
|
Root session switching, forking, and tree traversal are blocked while members are live. A member cannot switch away from its team binding. Stop members explicitly before replacing the root session.
|
|
55
57
|
|
|
56
58
|
### Exit confirmation needs a Pi hook
|
|
@@ -59,6 +61,12 @@ Exit confirmation requires Pi's `session_before_exit` hook. The prerequisite is
|
|
|
59
61
|
|
|
60
62
|
With that hook, native exit asks before stopping live members; cancellation leaves them running. Without it, shutdown performs cleanup but cannot ask first. Signals bypass confirmation.
|
|
61
63
|
|
|
64
|
+
### Coordinator handover
|
|
65
|
+
|
|
66
|
+
The main session holding `owner.lock` runs the coordinator. Before it exits it records whether each member was busy, idle, or waiting for input, then stops the members. Another connected main session takes the lock within seconds and runs the resume pass: busy members resume and are told their process restarted during a handover, idle members resume idle, and members waiting for input resume and ask again. The same pass runs when a main session opens a team with no coordinator running.
|
|
67
|
+
|
|
68
|
+
A main session that crashes is marked interrupted; any main session can resume it. Raising the member limit above 10 requires the coordinator's own session, because the confirmation prompt runs there.
|
|
69
|
+
|
|
62
70
|
### Crash recovery is best effort
|
|
63
71
|
|
|
64
72
|
If the root crashes, closing its owned pipes and losing the coordinator connection makes ordinary RPC children shut down. Native members also monitor the authenticated coordinator connection and exit after it is lost. This passed SIGTERM and SIGKILL probes with native child and grandchild processes.
|
|
@@ -75,11 +83,12 @@ Missing targets, dirty work, and uncertain/squashed integration retain the tree.
|
|
|
75
83
|
|
|
76
84
|
## Local records contain sensitive project data
|
|
77
85
|
|
|
78
|
-
Each root session uses `~/.
|
|
86
|
+
Each root session uses `~/.pi/ac-teams/teams/<session-id>/`:
|
|
79
87
|
|
|
80
88
|
- `state.json` contains membership, policy, tasks, messages, claims, and notification cursors
|
|
81
|
-
- `sessions
|
|
82
|
-
- `owner.lock` and `coordinator.sock` coordinate the running
|
|
89
|
+
- Member transcripts live under Main's session directory in `ac-teams/<team>/<member>/`, normally `~/.pi/agent/sessions/<project>/ac-teams/...`; Pi's `/resume` lists only top-level sessions, so use `/teams` to find them
|
|
90
|
+
- `owner.lock` and `coordinator.sock` coordinate the main session running the coordinator; other main sessions authenticate with tokens stored in `state.json`
|
|
91
|
+
- Promoted members' transcripts move to `<session folder>/` beside other top-level sessions, and their `~/.pi/ac-teams/agents/<id>` folder is removed
|
|
83
92
|
- Interactive teams also own `t.sock`, the private tmux socket, and per-member terminal environment files; shutdown removes those files
|
|
84
93
|
|
|
85
94
|
New members also store their descriptive names and optional model/context reports. When an existing team opens, unnamed members receive stable, collision-free names such as `teammate-1`; their IDs and sessions do not change.
|
|
@@ -88,7 +97,9 @@ State schema 3 adds stable task numbers and short labels. Opening a schema 1 or
|
|
|
88
97
|
|
|
89
98
|
Schema 1 also migrates progress: `pending` becomes `todo`, `claimed` becomes `in_progress`, and `completed` becomes `done`. Migration writes atomically and rejects invalid snapshots without replacement. Older ac-teams code cannot reopen schema 3; stop old sessions before updating.
|
|
90
99
|
|
|
91
|
-
|
|
100
|
+
State schema 4 adds a role to each record: `main` or `member`. Opening a schema 3 team makes the root record a main session and every other record a member. A record without a role has left the team. A promotion records each step, so a failure rolls the member back to a stopped member with its original transcript path.
|
|
101
|
+
|
|
102
|
+
Only the coordinator writes state. Mutations validate a copy, flush a temporary snapshot, atomically rename it, and flush the directory before returning success. An exclusive process-identity lock prevents concurrent owners. Private team directories use 0700; state files and the authenticated Unix socket use 0600. Uncertain locks fail closed.
|
|
92
103
|
|
|
93
104
|
This is a small-team store: each mutation rewrites the snapshot, and search scans local history. There is no database, compaction, retention policy, automatic deletion, or deduplication of repeated mutation requests. Inspect state before retrying an ambiguous failed request.
|
|
94
105
|
|
package/docs/usage.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Use ac-teams
|
|
2
2
|
|
|
3
|
-
ac-teams provides three tools without prescribing a workflow. Your Pi session is
|
|
3
|
+
ac-teams provides three tools without prescribing a workflow. Your Pi session is a main session; the agents it launches are members. The first main session opens coordination state on the first team request. See [main sessions](#main-sessions-share-one-team) for adding more.
|
|
4
4
|
|
|
5
5
|
| Tool | Actions | Main rule |
|
|
6
6
|
| --- | --- | --- |
|
|
@@ -8,7 +8,7 @@ ac-teams provides three tools without prescribing a workflow. Your Pi session is
|
|
|
8
8
|
| `team_task` | `create`, `list`, `read`, `edit`, `claim`, `start`, `release`, `complete`, `reopen` | Claim reserves work; dependencies gate starting, not ownership |
|
|
9
9
|
| `team_message` | `send`, `history`, `search` | Recipients select who receives notification, not who can read |
|
|
10
10
|
|
|
11
|
-
In interactive Pi, `/teams` or `/teams panel` opens the team panel. `/teams teammate <name-or-id>` opens a live teammate directly; autocomplete suggests subcommands and teammate names. You can also enter an exact teammate ID. The older `/teams open <id>` form remains supported. Headless roots retain the status listing. `/teams stop` stops every
|
|
11
|
+
In interactive Pi, `/teams` or `/teams panel` opens the team panel. `/teams teammate <name-or-id>` opens a live teammate directly; autocomplete suggests subcommands and teammate names. You can also enter an exact teammate ID. The older `/teams open <id>` form remains supported. Headless roots retain the status listing. `/teams stop` stops every member; main sessions keep running. `/teams join` and `/teams leave` change which team this session belongs to. See [operations](operations.md) for limits, recovery, and worktree cleanup.
|
|
12
12
|
|
|
13
13
|
## Navigate without stopping members
|
|
14
14
|
|
|
@@ -38,6 +38,17 @@ The `+N lines` hint shows the hidden detail count. Ctrl+O expands the full reque
|
|
|
38
38
|
|
|
39
39
|
Team notification messages remain compact; Ctrl+O expands their labeled fields. UI state colors do not replace the underlying progress, ownership, or dependency fields.
|
|
40
40
|
|
|
41
|
+
## Main sessions share one team
|
|
42
|
+
|
|
43
|
+
A team can have several main sessions, each a top-level Pi session a person drives. Members are the agents they launch. Every main session controls every member and can stop or resume other main sessions; a member controls only its own descendants and never a main session.
|
|
44
|
+
|
|
45
|
+
- **Promote:** On the panel's Teammates tab, `P` stops a member and relaunches it as a main session. It keeps its session ID and history, its transcript moves to the normal Pi session folder so `/resume` lists it, and it stays in the team. Under Herdr it opens in a new tab of the workspace for its worktree; otherwise it runs in its own tmux server and ac-teams prints the attach command. A member with enforced access asks first, because the promoted session runs without the container.
|
|
46
|
+
- **Join:** `/teams join` lists the teams in this project and adds this session to the chosen one as a main session, then posts a summary of open tasks, owners, and recent messages. It refuses a session already in that team, or one already in a team with other members.
|
|
47
|
+
- **Leave:** `/teams leave` returns this session's in-progress tasks to `todo`, clears their ownership, and drops its main-session role; its record keeps its history. The last main session cannot leave while members are live.
|
|
48
|
+
- **Resume:** A session remembers its team in its transcript. Resuming it reconnects to the team; if no coordinator is running, ac-teams offers to run it here. Declining turns the team tools off for this session.
|
|
49
|
+
|
|
50
|
+
One main session runs the coordinator; the others connect to it. When that session exits, another main session takes over automatically and resumes the members that were working. See [operations](operations.md#coordinator-handover).
|
|
51
|
+
|
|
41
52
|
## Show tasks and configure the displays
|
|
42
53
|
|
|
43
54
|
The task summary sits above the input editor; the agent selector sits below the editor. The task summary shows completed/total counts and up to five numbered task labels, ordered by:
|
|
@@ -133,6 +144,6 @@ Messages survive crashes; exactly-once delivery or model execution does not. Avo
|
|
|
133
144
|
|
|
134
145
|
ac-teams works alone. ACStack adds planning, review, and shipping skills; Herdr adds terminal tabs, panes, and visible root sessions. These are companion capabilities, not automatic integrations. ac-teams does not open a Herdr pane for each member. Interactive members use a team-private tmux server and open in the current terminal when selected; headless roots use RPC members.
|
|
135
146
|
|
|
136
|
-
Other delegation tools can coexist. ac-teams warns when `Agent`, `SubagentWorkflow`, or `subagent` is active, but does not disable them. Its limits cover only members launched through `team_agent`. Use Pi's resource settings to disable other launchers if you want one team authority. Shell access and same-user extensions are not sandboxed.
|
|
147
|
+
Other delegation tools can coexist. ac-teams warns when `Agent`, `SubagentWorkflow`, or `subagent` is active, but does not disable them. Its limits cover only members launched through `team_agent`. Use Pi's resource settings to disable other launchers if you want one team authority. Shell access and same-user extensions are not sandboxed unless a member is launched with [enforced access](access.md).
|
|
137
148
|
|
|
138
149
|
[Back to README](../README.md)
|
package/package.json
CHANGED
package/src/access.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { homedir, totalmem } from "node:os";
|
|
5
|
+
import { stateRoot } from "./config.ts";
|
|
6
|
+
import { assertNetworkNarrower, validatePolicy, type NetworkPolicy } from "./egress.ts";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** Canonical directory grants; write implies read. Undefined access means an unsandboxed member. */
|
|
10
|
+
export interface Access { read: string[]; write: string[]; network: NetworkPolicy }
|
|
11
|
+
export interface AccessRequest { read?: string[]; write?: string[]; network?: NetworkPolicy }
|
|
12
|
+
export interface SandboxStatus { available: boolean; backend: "docker"; reason?: string }
|
|
13
|
+
|
|
14
|
+
export const within = (path: string, root: string) => path === root || path.startsWith(root === "/" ? "/" : root + "/");
|
|
15
|
+
const inside = (path: string, roots: string[]) => roots.some(root => within(path, root));
|
|
16
|
+
export const readable = (access: Access, path: string) => inside(path, [...access.read, ...access.write]);
|
|
17
|
+
export const writable = (access: Access, path: string) => inside(path, access.write);
|
|
18
|
+
|
|
19
|
+
/** Pi's agent directory and ac-teams state hold credentials, transcripts, and coordinator tokens. */
|
|
20
|
+
export const privateRoots = () => [resolve(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent")), stateRoot()];
|
|
21
|
+
|
|
22
|
+
export function resolveAccess(request: AccessRequest, base: string): Access {
|
|
23
|
+
const canonical = (paths: string[] = []) => [...new Set(paths.map(path => {
|
|
24
|
+
let real: string;
|
|
25
|
+
try { real = realpathSync(resolve(base, path)); } catch { throw new Error(`Access path does not exist: ${path}`); }
|
|
26
|
+
if (!statSync(real).isDirectory()) throw new Error(`Access path must be a directory: ${path}`);
|
|
27
|
+
// A symlink in the path would be re-resolved by Docker at start; a sandboxed writer could have planted or could swap it.
|
|
28
|
+
if (real !== resolve(base, path)) throw new Error(`Access paths must be canonical, without symlinks: ${path} resolves to ${real}`);
|
|
29
|
+
// Docker's --mount syntax has no escaping, so a comma would inject mount options.
|
|
30
|
+
if (/[,\n]/.test(real)) throw new Error(`Access paths cannot contain commas or newlines: ${real}`);
|
|
31
|
+
// These would replace the sandbox's private /proc, /dev, /tmp, or home, or expose host sockets and credentials.
|
|
32
|
+
for (const unsafe of ["/proc", "/dev", "/sys", "/run"]) if (within(real, unsafe)) throw new Error(`Access to ${real} is not allowed`);
|
|
33
|
+
for (const covered of ["/tmp", "/run", "/var/run", homedir()]) if (within(covered, real)) throw new Error(`Access to ${real} would expose ${covered}; grant a narrower directory`);
|
|
34
|
+
for (const root of privateRoots()) if (within(real, root) || within(root, real)) throw new Error(`Access to ${real} would expose private Pi or ac-teams state in ${root}`);
|
|
35
|
+
return real;
|
|
36
|
+
}))].sort();
|
|
37
|
+
const write = canonical(request.write);
|
|
38
|
+
return { read: canonical(request.read).filter(path => !inside(path, write)), write, network: validatePolicy(request.network ?? {}) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Throws unless child grants are the same as or narrower than the actor's; an unsandboxed actor may grant anything. */
|
|
42
|
+
export function assertNarrower(actor: Access | undefined, child: Access | undefined): void {
|
|
43
|
+
if (!actor) return;
|
|
44
|
+
if (!child) throw new Error("A sandboxed member cannot launch or resume an unsandboxed member");
|
|
45
|
+
for (const path of child.write) if (!writable(actor, path)) throw new Error(`Write access to ${path} exceeds the requesting member's access`);
|
|
46
|
+
for (const path of child.read) if (!readable(actor, path)) throw new Error(`Read access to ${path} exceeds the requesting member's access`);
|
|
47
|
+
assertNetworkNarrower(actor.network, child.network);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Host paths a member mounts, and the subset it can write. */
|
|
51
|
+
export interface MountSet { sources: string[]; writes: string[] }
|
|
52
|
+
/** Grants are identity-checked at start, so only a member's other mounts count as sources; its writable grants are its writes. */
|
|
53
|
+
export const mountSet = (mounts: { source: string; rw: boolean }[], grants: string[]): MountSet => ({
|
|
54
|
+
sources: mounts.filter(mount => !grants.includes(mount.source)).map(mount => mount.source),
|
|
55
|
+
writes: mounts.filter(mount => mount.rw && grants.includes(mount.source)).map(mount => mount.source) });
|
|
56
|
+
/**
|
|
57
|
+
* Docker resolves bind sources when a container starts. A sandboxed writer can replace any entry strictly below its
|
|
58
|
+
* writable directory, so no member may mount a path strictly inside another live member's writable directory, in either
|
|
59
|
+
* direction. Equal paths are safe: a writer cannot replace its own mount point. A member's own non-grant mounts (runtime,
|
|
60
|
+
* bridge) must not sit inside its own writable directory either, or it could swap them before its next start.
|
|
61
|
+
*/
|
|
62
|
+
export function assertStableGrants(mine: MountSet, others: MountSet[]): void {
|
|
63
|
+
const below = (set: MountSet, writer: MountSet) => set.sources.find(path => writer.writes.some(root => path !== root && within(path, root)));
|
|
64
|
+
for (const other of [...others, mine]) {
|
|
65
|
+
const path = below(mine, other) ?? below(other, mine);
|
|
66
|
+
if (path) throw new Error(`Mount ${path} lies strictly inside a sandboxed member's writable directory; grant that directory or a path outside it`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Pi agent-directory entries members need: installed packages, extensions, themes, skills, prompts, and settings Pi reads at startup. */
|
|
71
|
+
export const agentRuntime = ["npm", "extensions", "themes", "skills", "prompts", "AGENTS.md", "keybindings.json", "acstack.json", "models-store.json"];
|
|
72
|
+
export const generatedAgentFiles = ["settings.json", "auth.json", "trust.json", "web-search.json"];
|
|
73
|
+
|
|
74
|
+
export function packageRoot(file: string): string {
|
|
75
|
+
for (let dir = dirname(realpathSync(file)); ; dir = dirname(dir)) {
|
|
76
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
77
|
+
if (dir === dirname(dir)) throw new Error(`No package root for ${file}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Container memory caps; the aggregate guard keeps the sum of caps within 75% of host RAM.
|
|
82
|
+
// shortcut: fixed per-member cap, add a setting if real workloads need more.
|
|
83
|
+
export const memberMemory = 2 * 1024 ** 3;
|
|
84
|
+
export const memberPids = 1024;
|
|
85
|
+
export const maxSandboxed = () => Math.max(1, Math.floor(totalmem() * 0.75 / memberMemory));
|
|
86
|
+
|
|
87
|
+
const dockerfile = new URL("./sandbox.Dockerfile", import.meta.url);
|
|
88
|
+
/** Image tag tied to the host Pi version and this Dockerfile, so members run the same Pi as Main. */
|
|
89
|
+
export function memberImage(piVersion: string): { tag: string; build: string } {
|
|
90
|
+
const hash = createHash("sha256").update(readFileSync(dockerfile)).digest("hex").slice(0, 12);
|
|
91
|
+
const tag = `ac-teams-member:${piVersion}-${hash}`, file = dockerfile.pathname;
|
|
92
|
+
return { tag, build: `docker build -t ${tag} --build-arg PI_VERSION=${piVersion} -f ${file} ${dirname(file)}` };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function sandboxStatus(image: { tag: string; build: string }, docker = "docker"): SandboxStatus {
|
|
96
|
+
const status = (reason?: string): SandboxStatus => ({ available: !reason, backend: "docker", ...(reason ? { reason } : {}) });
|
|
97
|
+
if (process.platform !== "linux") return status("Enforced access requires Linux");
|
|
98
|
+
const probe = spawnSync(docker, ["image", "inspect", "--format", "{{json .Config.Volumes}}", image.tag], { encoding: "utf8", timeout: 10000 });
|
|
99
|
+
if (probe.error) return status(`Docker is unavailable: ${probe.error.message}`);
|
|
100
|
+
if (probe.status !== 0) return status(/No such image/i.test(probe.stderr) ? `Member image ${image.tag} is missing; build it with: ${image.build}` : `Docker cannot be used: ${probe.stderr.trim().slice(0, 300)}`);
|
|
101
|
+
if (!["null", "{}"].includes(probe.stdout.trim())) return status(`Member image ${image.tag} declares volumes; rebuild it from the shipped Dockerfile`);
|
|
102
|
+
return status();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SandboxPlan {
|
|
106
|
+
name: string; labels: Record<string, string>; image: string; access: Access; cwd: string; home: string; terminal: boolean;
|
|
107
|
+
agentDir: string; configDir: string; sessionDir: string; coordinatorSocket: string; egressSocket: string;
|
|
108
|
+
runtime: string[]; launch: string; bridge: string;
|
|
109
|
+
}
|
|
110
|
+
export interface Mount { source: string; target: string; readonly: boolean }
|
|
111
|
+
/** Every host path a member container sees. Nothing else on the host is mounted. */
|
|
112
|
+
export function sandboxMounts(plan: SandboxPlan): Mount[] {
|
|
113
|
+
const mounts: Mount[] = [
|
|
114
|
+
...plan.access.read.map(path => ({ source: path, target: path, readonly: true })),
|
|
115
|
+
...plan.access.write.map(path => ({ source: path, target: path, readonly: false })),
|
|
116
|
+
// Runtime code stays read-only even inside a writable grant, because unsandboxed processes load it too.
|
|
117
|
+
...plan.runtime.map(path => ({ source: path, target: path, readonly: true })),
|
|
118
|
+
...agentRuntime.map(name => join(plan.agentDir, name)).filter(path => existsSync(path)).map(path => ({ source: path, target: path, readonly: true })),
|
|
119
|
+
...generatedAgentFiles.map(name => ({ source: join(plan.configDir, name), target: join(plan.agentDir, name), readonly: true })),
|
|
120
|
+
{ source: plan.launch, target: plan.launch, readonly: true },
|
|
121
|
+
{ source: plan.sessionDir, target: plan.sessionDir, readonly: false },
|
|
122
|
+
{ source: plan.coordinatorSocket, target: plan.coordinatorSocket, readonly: false },
|
|
123
|
+
{ source: plan.egressSocket, target: plan.egressSocket, readonly: false },
|
|
124
|
+
];
|
|
125
|
+
return mounts;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function dockerCreateArgs(plan: SandboxPlan): string[] {
|
|
129
|
+
const uid = process.getuid!(), gid = process.getgid!();
|
|
130
|
+
const args = ["create", "--name", plan.name, "--interactive", ...(plan.terminal ? ["--tty"] : []), "--init",
|
|
131
|
+
"--network", "none", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--read-only", "--restart", "no",
|
|
132
|
+
"--user", `${uid}:${gid}`, "--memory", String(memberMemory), "--memory-swap", String(memberMemory), "--pids-limit", String(memberPids),
|
|
133
|
+
"--log-driver", "none", "--hostname", "ac-teams",
|
|
134
|
+
"--tmpfs", "/tmp:rw,nosuid,nodev,size=512m", "--tmpfs", `${plan.home}:rw,nosuid,nodev,size=256m,uid=${uid},gid=${gid},mode=0700`,
|
|
135
|
+
// Pi writes lock files and caches in its agent directory; this private scratch copy hides the real one.
|
|
136
|
+
"--tmpfs", `${plan.agentDir}:rw,nosuid,nodev,size=64m,uid=${uid},gid=${gid},mode=0700`];
|
|
137
|
+
for (const [key, value] of Object.entries(plan.labels)) args.push("--label", `${key}=${value}`);
|
|
138
|
+
// Other coordinators use this to tell grants (identity-checked at start) from runtime mounts.
|
|
139
|
+
args.push("--label", `ac-teams.grants=${JSON.stringify([...plan.access.read, ...plan.access.write])}`);
|
|
140
|
+
for (const mount of sandboxMounts(plan)) args.push("--mount", `type=bind,source=${mount.source},target=${mount.target}${mount.readonly ? ",readonly" : ""}`);
|
|
141
|
+
args.push("--env", "PATH=/usr/local/bin:/usr/bin:/bin", "--env", `HOME=${plan.home}`, "--env", "TMPDIR=/tmp",
|
|
142
|
+
"--workdir", plan.cwd, plan.image, "node", plan.bridge, plan.launch);
|
|
143
|
+
return args;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Checks docker inspect output against the policy; any problem means the container must not start. */
|
|
147
|
+
export function containerProblems(inspect: any, plan: SandboxPlan, started = false): string[] {
|
|
148
|
+
const problems: string[] = [], host = inspect?.HostConfig ?? {}, config = inspect?.Config ?? {};
|
|
149
|
+
const expect = (ok: boolean, problem: string) => { if (!ok) problems.push(problem); };
|
|
150
|
+
expect(host.NetworkMode === "none", `network mode ${host.NetworkMode}`);
|
|
151
|
+
expect(!Object.keys(host.PortBindings ?? {}).length && !host.PublishAllPorts && !Object.keys(config.ExposedPorts ?? {}).length, "ports are published or exposed");
|
|
152
|
+
expect(host.Privileged === false, "privileged");
|
|
153
|
+
expect(JSON.stringify(host.CapDrop) === '["ALL"]' && !(host.CapAdd ?? []).length, "capabilities are not all dropped");
|
|
154
|
+
const security: string[] = host.SecurityOpt ?? [];
|
|
155
|
+
expect(security.length > 0 && security.every(option => /^no-new-privileges(:true)?$/.test(option)), "security options differ from no-new-privileges");
|
|
156
|
+
expect(host.ReadonlyRootfs === true, "root filesystem is writable");
|
|
157
|
+
expect(["no", ""].includes(host.RestartPolicy?.Name ?? ""), "restart policy is set");
|
|
158
|
+
expect(host.LogConfig?.Type === "none", "container logging is enabled");
|
|
159
|
+
expect(!host.PidMode && ["", "private"].includes(host.IpcMode ?? "") && !host.UTSMode && !host.UsernsMode && !host.CgroupnsMode?.startsWith?.("host"), "host namespaces are shared");
|
|
160
|
+
expect(!(host.Devices ?? []).length && !(host.DeviceRequests ?? []).length && !(host.GroupAdd ?? []).length && !(host.VolumesFrom ?? []).length, "devices, groups, or volumes-from are added");
|
|
161
|
+
expect(host.Memory === memberMemory && host.PidsLimit === memberPids, "resource limits are missing");
|
|
162
|
+
expect(config.User === `${process.getuid!()}:${process.getgid!()}`, `user ${config.User}`);
|
|
163
|
+
expect(!Object.keys(config.Volumes ?? {}).length, "the image declares volumes");
|
|
164
|
+
const allowed = new Map(sandboxMounts(plan).map(mount => [mount.target, mount]));
|
|
165
|
+
const mounts: any[] = inspect?.Mounts ?? [];
|
|
166
|
+
expect(mounts.length === allowed.size, `expected ${allowed.size} mounts, found ${mounts.length}`);
|
|
167
|
+
for (const mount of mounts) {
|
|
168
|
+
const wanted = allowed.get(mount.Destination);
|
|
169
|
+
expect(mount.Type === "bind" && !!wanted && mount.Source === wanted.source && mount.RW === !wanted.readonly, `unexpected mount ${mount.Type} ${mount.Source} -> ${mount.Destination}`);
|
|
170
|
+
expect(!/docker\.sock$/.test(mount.Source ?? ""), "the Docker socket is mounted");
|
|
171
|
+
}
|
|
172
|
+
expect(JSON.stringify(Object.keys(host.Tmpfs ?? {}).sort()) === JSON.stringify(["/tmp", plan.home, plan.agentDir].sort()), "unexpected tmpfs mounts");
|
|
173
|
+
if (started) expect(inspect?.NetworkSettings && !Object.keys(inspect.NetworkSettings.Ports ?? {}).length, "ports are bound after start");
|
|
174
|
+
return problems;
|
|
175
|
+
}
|
package/src/commands.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { memberName, type Member } from "./types.ts";
|
|
|
3
3
|
import { cleanText } from "./render.ts";
|
|
4
4
|
|
|
5
5
|
type NamedMember = Pick<Member, "id" | "name" | "status">;
|
|
6
|
-
const actions = ["panel", "teammate", "config", "back", "stop", "status"];
|
|
6
|
+
const actions = ["panel", "teammate", "config", "back", "stop", "status", "join", "leave"];
|
|
7
7
|
|
|
8
8
|
export function resolveTeammate(members: NamedMember[], value: string): string | undefined {
|
|
9
9
|
const key = value === "main" ? "root" : value;
|