@phnx-labs/agents-cli 1.22.34 → 1.22.35
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/CHANGELOG.md +6 -0
- package/README.md +7 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/share.d.ts +18 -0
- package/dist/commands/share.js +108 -0
- package/dist/lib/codex-policy.d.ts +9 -1
- package/dist/lib/codex-policy.js +17 -2
- package/dist/lib/exec.js +2 -2
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/project-key.d.ts +17 -0
- package/dist/lib/project-key.js +26 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/share/delete.d.ts +93 -0
- package/dist/lib/share/delete.js +127 -0
- package/dist/lib/shims.js +39 -2
- package/dist/lib/startup/command-registry.js +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.35
|
|
4
|
+
|
|
5
|
+
- **`agents share delete` (alias `agents unshare`) takes down a published page (RUSH-2428).** `agents share` publishes to a public URL with no way to take one down — the Cloudflare Worker already implements an authed `DELETE`, the CLI just never exposed it. The new command accepts a full share URL, `<user>/<slug>`, or a bare `<slug>` (resolved against your own namespace exactly as publish does), takes several targets at once, and by default also deletes the sibling `<slug>.png` OG cover (`--keep-cover` opts out) — without it, republishing over a slug replaced the page but left the old cover screenshot publicly readable, which is what made a real takedown slow. `{"ok":true}` from the Worker is not treated as proof: a follow-up check must resolve 404 before the command reports success, and it errors loudly (non-zero) instead of if it can't verify the object is actually gone. An already-missing target is an error by default; `--if-exists` treats it as a no-op success. Source: `apps/cli/src/lib/share/delete.ts`, `apps/cli/src/commands/share.ts`.
|
|
6
|
+
|
|
7
|
+
- **Codex `edit` runs can write under the repo's `.agents/` again.** Codex's `workspace-write` sandbox hardcodes any `.agents/` (and `.codex/`) directory as read-only, but agents-cli keeps every git worktree at `<repo>/.agents/worktrees/<slug>` — so a Codex session whose cwd was the repo root hit `EROFS: read-only file system` on any write into a worktree (a build's `dist/`, generated files) and then had to prompt for per-command approval to escalate. Every interactive, headless, and direct-launch Codex path now makes the run's `<repo-root>/.agents` writable: `agents run codex` (and the Windows shim delegate) add it to the `agents-edit` profile's `workspace_roots`, and the adopted POSIX `codex` shim resolves the repo's `.agents` from `$PWD` at run time (worktree-aware) and passes it via Codex's own `--add-dir`. (Routines use a separate overlay-HOME sandbox with their own `allow.dirs` and are unchanged.) Naming the `.agents` directory itself is the only override Codex honors — a nested sub-path makes bwrap refuse the mount. Only an existing `.agents` is added; out-of-workspace and `~/.config` writes stay gated, network unchanged. Source: `apps/cli/src/lib/codex-policy.ts`, `apps/cli/src/lib/project-key.ts`, `apps/cli/src/lib/exec.ts`, `apps/cli/src/lib/shims.ts`.
|
|
8
|
+
|
|
3
9
|
## 1.22.34
|
|
4
10
|
|
|
5
11
|
- **A failed `agents teams add` no longer strands an `agents/<name>` branch that breaks every retry (RUSH-2356).** The worktree was created before the teammate record was persisted and nothing removed it when the add failed, so the next `teams add` with the same `--worktree` name died on `fatal: a branch named 'agents/<name>' already exists` — observed 2026-08-07, forcing three renames before a teammate could be created. Two guarantees now hold. Name uniqueness and the `--after` dependency graph are validated **before** `createWorktree` runs (`AgentManager.validateAddPreconditions`), so a duplicate name, unknown dependency, or cycle never creates a branch at all. A failure **after** the worktree exists — a missing harness CLI, a launch error, a cloud dispatch failure — removes the worktree and its branch before the command exits non-zero, and prints the exact `git worktree remove` / `git branch -D` pair if that teardown itself fails. Teardown is scoped twice over, because deleting a live teammate's worktree would destroy real work: only a worktree that same add created is a candidate, and only when no live teammate claims it (`AgentManager.isWorktreeClaimed`, a raw meta.json scan across every team, counting only non-terminal records). That second check matters because the add can fail *after* the record is durably saved — `spawn()` saves a staged teammate and only then runs the retention pass, which refreshes every sibling and can throw on a distributed one — so a teammate already recorded and waiting on an `--after` dependency keeps its worktree. If we can't prove a worktree is an orphan, it is left in place with the manual removal printed — the claim check **fails closed**, so an unreadable or half-written record answers "claimed" rather than "free": only a genuinely absent record (ENOENT) proves nothing claims the worktree, because the alternative error deletes a running teammate's uncommitted work. The pr-watch fixer path (`reactWithTeammate`) and a partially-failed `createWorktree` (branch ref created, checkout not) run the same guarded teardown — the fixer path matters because it stages its teammate with `--after` when it follows a source teammate, so it can reach the failure branch with a live, durably-recorded, merely-pending teammate already owning the worktree.
|
package/README.md
CHANGED
|
@@ -1122,6 +1122,7 @@ agents share setup # once: provision bucket + W
|
|
|
1122
1122
|
agents share plan.html --slug fleet --expire 30d # → https://<base>/fleet
|
|
1123
1123
|
agents share plan.html --json # URL object for plan-render hooks
|
|
1124
1124
|
agents share status # show the endpoint
|
|
1125
|
+
agents unshare fleet # take a published link (+ its OG cover) down
|
|
1125
1126
|
```
|
|
1126
1127
|
|
|
1127
1128
|
`agents share` closes the loop: an agent makes work (a plan, a viz, a report),
|
|
@@ -1138,6 +1139,12 @@ publishes through it with a shared write token — `agents share join <baseUrl>`
|
|
|
1138
1139
|
existing endpoint with no provisioning. `--expire 30d|12h|<date>` auto-expires a link.
|
|
1139
1140
|
`--json` emits `{ url, coverUrl, expiresAt }` so plan-render automation can publish the
|
|
1140
1141
|
rendered HTML and post the returned link without scraping terminal text.
|
|
1142
|
+
|
|
1143
|
+
`agents share delete <targets...>` (alias `agents unshare`) takes a page down — pass a
|
|
1144
|
+
full URL, `<user>/<slug>`, or a bare slug (resolved against your own namespace); several
|
|
1145
|
+
targets at once are fine. It also deletes the sibling `<slug>.png` OG cover by default
|
|
1146
|
+
(`--keep-cover` opts out) and verifies the page actually 404s before reporting success —
|
|
1147
|
+
the Worker's delete is idempotent, so `{"ok":true}` alone is never proof.
|
|
1141
1148
|
See [docs/share.md](apps/cli/docs/share.md).
|
|
1142
1149
|
|
|
1143
1150
|
---
|
package/dist/bin/agents
CHANGED
|
Binary file
|
package/dist/commands/share.d.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
2
|
import { type CloudflareRequester } from '../lib/share/provision.js';
|
|
3
3
|
import { type PublishResult } from '../lib/share/publish.js';
|
|
4
|
+
import { deleteShare, type DeleteShareResult } from '../lib/share/delete.js';
|
|
4
5
|
export declare function formatSharePublishResult(result: PublishResult, json?: boolean): string;
|
|
6
|
+
export declare function formatShareDeleteResult(result: DeleteShareResult, json?: boolean): string;
|
|
7
|
+
interface ShareDeleteCliOpts {
|
|
8
|
+
keepCover?: boolean;
|
|
9
|
+
ifExists?: boolean;
|
|
10
|
+
githubUser?: string;
|
|
11
|
+
json?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Shared handler for `agents share delete <targets...>` and the top-level
|
|
14
|
+
* `agents unshare <targets...>` alias. Deletes each target independently and
|
|
15
|
+
* continues past a failed one (rm-style), reporting all results and exiting
|
|
16
|
+
* non-zero if any target failed to verify as gone.
|
|
17
|
+
*
|
|
18
|
+
* `deleteFn` is a DI seam for tests (defaults to the real `deleteShare`) — it is
|
|
19
|
+
* never exposed as a CLI flag, only used to inject a fake config/checker/deleter
|
|
20
|
+
* without touching the keychain or a live endpoint. */
|
|
21
|
+
export declare function runShareDelete(targets: string[], opts: ShareDeleteCliOpts, deleteFn?: typeof deleteShare): Promise<void>;
|
|
5
22
|
export declare function registerShareCommands(program: Command): void;
|
|
6
23
|
/** Provision a fresh R2 bucket + Worker on the user's Cloudflare and persist the
|
|
7
24
|
* endpoint config + write token. Shared by `agents share setup` and the unified
|
|
@@ -22,3 +39,4 @@ export declare function runShareProvision(opts: {
|
|
|
22
39
|
export declare function runShareJoin(baseUrl?: string, opts?: {
|
|
23
40
|
token?: string;
|
|
24
41
|
}): Promise<void>;
|
|
42
|
+
export {};
|
package/dist/commands/share.js
CHANGED
|
@@ -5,9 +5,11 @@ import chalk from 'chalk';
|
|
|
5
5
|
import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_SHARE_DOMAIN, DEFAULT_WORKER_NAME, generateWriteToken, readCloudflareCreds, readShareConfig, readWriteTokenEnv, readWriteTokenFromBundle, storeWriteToken, writeShareConfig, } from '../lib/share/config.js';
|
|
6
6
|
import { addCustomDomain, configureBucketLifecycle, createBucket, deployWorker, enableWorkersDev, findZoneId, setWorkerSecret, } from '../lib/share/provision.js';
|
|
7
7
|
import { publishFile } from '../lib/share/publish.js';
|
|
8
|
+
import { deleteShare } from '../lib/share/delete.js';
|
|
8
9
|
import { renderWorkerScript } from '../lib/share/worker-template.js';
|
|
9
10
|
import { analyticsEnabled } from '../lib/share/analytics.js';
|
|
10
11
|
import { resolveGitHubUsername } from '../lib/git.js';
|
|
12
|
+
import { setHelpSections } from '../lib/help.js';
|
|
11
13
|
export function formatSharePublishResult(result, json = false) {
|
|
12
14
|
if (json)
|
|
13
15
|
return JSON.stringify(result, null, 2);
|
|
@@ -18,6 +20,84 @@ export function formatSharePublishResult(result, json = false) {
|
|
|
18
20
|
lines.push(chalk.dim(` expires ${new Date(result.expiresAt).toLocaleString()}`));
|
|
19
21
|
return lines.join('\n');
|
|
20
22
|
}
|
|
23
|
+
export function formatShareDeleteResult(result, json = false) {
|
|
24
|
+
if (json)
|
|
25
|
+
return JSON.stringify(result, null, 2);
|
|
26
|
+
if (result.skipped)
|
|
27
|
+
return chalk.dim(`skipped — ${result.url} was already gone`);
|
|
28
|
+
const lines = [chalk.green(`deleted ${result.url}`)];
|
|
29
|
+
if (result.cover) {
|
|
30
|
+
lines.push(result.cover.existedBefore
|
|
31
|
+
? chalk.dim(` cover deleted ${result.cover.url}`)
|
|
32
|
+
: chalk.dim(` cover (none) ${result.cover.url}`));
|
|
33
|
+
}
|
|
34
|
+
return lines.join('\n');
|
|
35
|
+
}
|
|
36
|
+
/** Shared handler for `agents share delete <targets...>` and the top-level
|
|
37
|
+
* `agents unshare <targets...>` alias. Deletes each target independently and
|
|
38
|
+
* continues past a failed one (rm-style), reporting all results and exiting
|
|
39
|
+
* non-zero if any target failed to verify as gone.
|
|
40
|
+
*
|
|
41
|
+
* `deleteFn` is a DI seam for tests (defaults to the real `deleteShare`) — it is
|
|
42
|
+
* never exposed as a CLI flag, only used to inject a fake config/checker/deleter
|
|
43
|
+
* without touching the keychain or a live endpoint. */
|
|
44
|
+
export async function runShareDelete(targets, opts, deleteFn = deleteShare) {
|
|
45
|
+
const results = [];
|
|
46
|
+
for (const target of targets) {
|
|
47
|
+
try {
|
|
48
|
+
const result = await deleteFn(target, {
|
|
49
|
+
keepCover: opts.keepCover === true,
|
|
50
|
+
ifExists: opts.ifExists === true,
|
|
51
|
+
githubUser: opts.githubUser,
|
|
52
|
+
});
|
|
53
|
+
results.push({ target, result });
|
|
54
|
+
if (!opts.json)
|
|
55
|
+
console.log(formatShareDeleteResult(result));
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
results.push({ target, error: e.message });
|
|
59
|
+
if (!opts.json)
|
|
60
|
+
console.error(chalk.red(`${target}: ${e.message}`));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (opts.json) {
|
|
64
|
+
console.log(JSON.stringify(results, null, 2));
|
|
65
|
+
}
|
|
66
|
+
if (results.some((r) => r.error)) {
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function registerShareDeleteOptions(cmd) {
|
|
71
|
+
return cmd
|
|
72
|
+
.option('--keep-cover', 'leave the sibling <slug>.png OG cover in place (default: delete it too)')
|
|
73
|
+
.option('--if-exists', 'treat an already-missing target as a no-op success instead of an error')
|
|
74
|
+
.option('--github-user <user>', 'GitHub username for resolving a bare-slug target (default: resolved from gh/git config)')
|
|
75
|
+
.option('--json', 'emit machine-readable results');
|
|
76
|
+
}
|
|
77
|
+
const SHARE_DELETE_EXAMPLES = `
|
|
78
|
+
# Delete by full URL — also takes down the sibling OG cover
|
|
79
|
+
agents share delete https://share.agents-cli.sh/octocat/my-plan-a1b2
|
|
80
|
+
|
|
81
|
+
# Delete by <user>/<slug>, or a bare slug in your own namespace
|
|
82
|
+
agents share delete octocat/my-plan-a1b2
|
|
83
|
+
agents unshare my-plan-a1b2
|
|
84
|
+
|
|
85
|
+
# Several at once
|
|
86
|
+
agents unshare my-plan-a1b2 old-report-9f3c
|
|
87
|
+
|
|
88
|
+
# Keep the cover image up (rare — you usually want both gone)
|
|
89
|
+
agents unshare my-plan-a1b2 --keep-cover
|
|
90
|
+
|
|
91
|
+
# Don't error if it's already gone
|
|
92
|
+
agents unshare my-plan-a1b2 --if-exists
|
|
93
|
+
`;
|
|
94
|
+
const SHARE_DELETE_NOTES = `
|
|
95
|
+
A follow-up GET is required to resolve 404 before this reports success — the
|
|
96
|
+
Worker's DELETE is idempotent and returns {"ok":true} even for a key that was
|
|
97
|
+
never there, so that response alone is never proof of a takedown.
|
|
98
|
+
|
|
99
|
+
agents share delete === agents unshare (same command, different name).
|
|
100
|
+
`;
|
|
21
101
|
export function registerShareCommands(program) {
|
|
22
102
|
const shareCmd = program
|
|
23
103
|
.command('share')
|
|
@@ -54,6 +134,34 @@ export function registerShareCommands(program) {
|
|
|
54
134
|
process.exitCode = 1;
|
|
55
135
|
}
|
|
56
136
|
});
|
|
137
|
+
setHelpSections(shareCmd, {
|
|
138
|
+
examples: `
|
|
139
|
+
# Publish an HTML file — gets an auto OG cover + a shareable link
|
|
140
|
+
agents share ./out/plan.html
|
|
141
|
+
|
|
142
|
+
# Custom slug, expiring in 30 days
|
|
143
|
+
agents share ./out/report.html --slug q3-report --expire 30d
|
|
144
|
+
${SHARE_DELETE_EXAMPLES}
|
|
145
|
+
# One-time setup (or join an existing endpoint)
|
|
146
|
+
agents share setup
|
|
147
|
+
agents share join https://share.agents-cli.sh
|
|
148
|
+
`,
|
|
149
|
+
notes: SHARE_DELETE_NOTES,
|
|
150
|
+
});
|
|
151
|
+
const shareDeleteCmd = registerShareDeleteOptions(shareCmd
|
|
152
|
+
.command('delete <targets...>')
|
|
153
|
+
.description('Take down a published page (and by default its OG cover). Verifies the page 404s before reporting success. Top-level alias: agents unshare.'));
|
|
154
|
+
setHelpSections(shareDeleteCmd, { examples: SHARE_DELETE_EXAMPLES, notes: SHARE_DELETE_NOTES });
|
|
155
|
+
shareDeleteCmd.action(async (targets, opts) => {
|
|
156
|
+
await runShareDelete(targets, opts);
|
|
157
|
+
});
|
|
158
|
+
const unshareCmd = registerShareDeleteOptions(program
|
|
159
|
+
.command('unshare <targets...>')
|
|
160
|
+
.description('Alias of `agents share delete` — take down a published page (and by default its OG cover).'));
|
|
161
|
+
setHelpSections(unshareCmd, { examples: SHARE_DELETE_EXAMPLES, notes: SHARE_DELETE_NOTES });
|
|
162
|
+
unshareCmd.action(async (targets, opts) => {
|
|
163
|
+
await runShareDelete(targets, opts);
|
|
164
|
+
});
|
|
57
165
|
shareCmd
|
|
58
166
|
.command('setup')
|
|
59
167
|
.description('One-time: provision an R2 bucket + Worker on your Cloudflare and save the config.')
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
export type CodexPolicyMode = 'plan' | 'edit' | 'skip';
|
|
2
2
|
export declare const CODEX_PLAN_PROFILE = "agents-plan";
|
|
3
3
|
export declare const CODEX_EDIT_PROFILE = "agents-edit";
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Writable roots for Codex's `edit` profile: the managed user `.agents` dir, the
|
|
6
|
+
* baseline toolchain caches, and — when `cwd` is inside a repo — that repo's
|
|
7
|
+
* `.agents` directory. The last entry is what lets an in-repo build write under
|
|
8
|
+
* `.agents/worktrees/`; Codex's `workspace-write` sandbox hardcodes `.agents/`
|
|
9
|
+
* read-only, and naming the directory as an explicit writable root is the only
|
|
10
|
+
* thing that overrides it (a nested sub-path does not — bwrap refuses the mount).
|
|
11
|
+
*/
|
|
12
|
+
export declare function codexEditWritableRoots(cwd?: string): string[];
|
|
5
13
|
export declare function codexPermissionProfileConfig(mode: Exclude<CodexPolicyMode, 'skip'>, writableRoots?: string[]): string;
|
|
6
14
|
/**
|
|
7
15
|
* Canonical Codex safety policy used by every native launch path.
|
package/dist/lib/codex-policy.js
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
1
2
|
import { getUserAgentsDir } from './state.js';
|
|
2
3
|
import { codexDefaultWritableRoots } from './permissions.js';
|
|
4
|
+
import { repoAgentsDirForCwd } from './project-key.js';
|
|
3
5
|
export const CODEX_PLAN_PROFILE = 'agents-plan';
|
|
4
6
|
export const CODEX_EDIT_PROFILE = 'agents-edit';
|
|
5
7
|
function unique(values) {
|
|
6
8
|
return [...new Set(values)];
|
|
7
9
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Writable roots for Codex's `edit` profile: the managed user `.agents` dir, the
|
|
12
|
+
* baseline toolchain caches, and — when `cwd` is inside a repo — that repo's
|
|
13
|
+
* `.agents` directory. The last entry is what lets an in-repo build write under
|
|
14
|
+
* `.agents/worktrees/`; Codex's `workspace-write` sandbox hardcodes `.agents/`
|
|
15
|
+
* read-only, and naming the directory as an explicit writable root is the only
|
|
16
|
+
* thing that overrides it (a nested sub-path does not — bwrap refuses the mount).
|
|
17
|
+
*/
|
|
18
|
+
export function codexEditWritableRoots(cwd) {
|
|
19
|
+
const repoAgents = repoAgentsDirForCwd(cwd);
|
|
20
|
+
// Only widen the sandbox for a `.agents` that actually exists — most repos
|
|
21
|
+
// have none, and there is no point naming a directory that isn't there. (Codex
|
|
22
|
+
// tolerates a missing writable root, so this is tidiness, not a hard guard.)
|
|
23
|
+
const repoRoots = repoAgents && fs.existsSync(repoAgents) ? [repoAgents] : [];
|
|
24
|
+
return unique([getUserAgentsDir(), ...codexDefaultWritableRoots(), ...repoRoots]);
|
|
10
25
|
}
|
|
11
26
|
function inlineWorkspaceRoots(roots) {
|
|
12
27
|
return roots.map((root) => `${JSON.stringify(root)} = true`).join(', ');
|
package/dist/lib/exec.js
CHANGED
|
@@ -939,7 +939,7 @@ export function buildExecCommand(options) {
|
|
|
939
939
|
}
|
|
940
940
|
if (options.agent === 'codex') {
|
|
941
941
|
const policyMode = resolvedMode === 'plan' || resolvedMode === 'skip' ? resolvedMode : 'edit';
|
|
942
|
-
const writableRoots = [...codexEditWritableRoots(), ...(options.addDirs ?? [])];
|
|
942
|
+
const writableRoots = [...codexEditWritableRoots(options.cwd ?? process.cwd()), ...(options.addDirs ?? [])];
|
|
943
943
|
cmd.push(...codexPolicyArgs(policyMode, writableRoots));
|
|
944
944
|
}
|
|
945
945
|
else if (resumeSpec && 'subcommand' in resumeSpec) {
|
|
@@ -1181,7 +1181,7 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
|
|
|
1181
1181
|
// Match the POSIX shim: direct Codex launches default to the safe writable
|
|
1182
1182
|
// profile, while later user arguments can still override native settings.
|
|
1183
1183
|
const launchArgs = agent === 'codex'
|
|
1184
|
-
? ['-c', 'check_for_update_on_startup=false', ...codexPolicyArgs('edit')]
|
|
1184
|
+
? ['-c', 'check_for_update_on_startup=false', ...codexPolicyArgs('edit', codexEditWritableRoots(cwd))]
|
|
1185
1185
|
: [];
|
|
1186
1186
|
// Mint a launch id and export it as AGENT_LAUNCH_ID so the agent's SessionStart
|
|
1187
1187
|
// hook records the same id — the join key that maps this launch to its exact
|
|
Binary file
|
|
Binary file
|
|
@@ -31,6 +31,23 @@ export declare function projectKeyFromCwd(cwd?: string | null): string | undefin
|
|
|
31
31
|
* non-project directory under it into one bogus "project".
|
|
32
32
|
*/
|
|
33
33
|
export declare function repoRootForCwd(dir: string, home?: string): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* The main-repo `.agents` directory for a cwd, or `undefined` when the cwd is
|
|
36
|
+
* not inside a repo.
|
|
37
|
+
*
|
|
38
|
+
* For a worktree cwd (`…/<repo>/.agents/worktrees/<slug>[/sub]`) this resolves
|
|
39
|
+
* to the PRIMARY repo's `.agents` — the directory that actually holds the
|
|
40
|
+
* worktrees — so a run started anywhere in the repo can write into any worktree.
|
|
41
|
+
* For any other in-repo cwd it is `<repo-root>/.agents`.
|
|
42
|
+
*
|
|
43
|
+
* The single caller is Codex's `edit`-mode writable-root list: Codex's
|
|
44
|
+
* `workspace-write` sandbox hardcodes `.agents/` (and `.codex/`) as read-only,
|
|
45
|
+
* but naming the `.agents` directory itself as an explicit writable root
|
|
46
|
+
* overrides that — so an in-repo build/test never hits EROFS on a path under
|
|
47
|
+
* `.agents/worktrees/`. Filesystem-only (no `git` process): reuses the same
|
|
48
|
+
* worktree-segment fold as {@link projectKeyFromCwd}.
|
|
49
|
+
*/
|
|
50
|
+
export declare function repoAgentsDirForCwd(cwd?: string | null, home?: string): string | undefined;
|
|
34
51
|
/**
|
|
35
52
|
* Resolve the project key for a cwd **on this machine**: the repository it
|
|
36
53
|
* belongs to when there is one (so a monorepo subdir like `<repo>/apps/cli`
|
package/dist/lib/project-key.js
CHANGED
|
@@ -61,6 +61,32 @@ export function repoRootForCwd(dir, home = os.homedir()) {
|
|
|
61
61
|
current = parent;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The main-repo `.agents` directory for a cwd, or `undefined` when the cwd is
|
|
66
|
+
* not inside a repo.
|
|
67
|
+
*
|
|
68
|
+
* For a worktree cwd (`…/<repo>/.agents/worktrees/<slug>[/sub]`) this resolves
|
|
69
|
+
* to the PRIMARY repo's `.agents` — the directory that actually holds the
|
|
70
|
+
* worktrees — so a run started anywhere in the repo can write into any worktree.
|
|
71
|
+
* For any other in-repo cwd it is `<repo-root>/.agents`.
|
|
72
|
+
*
|
|
73
|
+
* The single caller is Codex's `edit`-mode writable-root list: Codex's
|
|
74
|
+
* `workspace-write` sandbox hardcodes `.agents/` (and `.codex/`) as read-only,
|
|
75
|
+
* but naming the `.agents` directory itself as an explicit writable root
|
|
76
|
+
* overrides that — so an in-repo build/test never hits EROFS on a path under
|
|
77
|
+
* `.agents/worktrees/`. Filesystem-only (no `git` process): reuses the same
|
|
78
|
+
* worktree-segment fold as {@link projectKeyFromCwd}.
|
|
79
|
+
*/
|
|
80
|
+
export function repoAgentsDirForCwd(cwd, home) {
|
|
81
|
+
if (!cwd)
|
|
82
|
+
return undefined;
|
|
83
|
+
const norm = cwd.replace(/\\/g, '/').replace(/\/+$/, '').trim();
|
|
84
|
+
if (!norm)
|
|
85
|
+
return undefined;
|
|
86
|
+
const wtIdx = norm.indexOf(WORKTREE_SEGMENT);
|
|
87
|
+
const repoRoot = wtIdx > 0 ? norm.slice(0, wtIdx) : repoRootForCwd(norm, home);
|
|
88
|
+
return repoRoot ? path.join(repoRoot, '.agents') : undefined;
|
|
89
|
+
}
|
|
64
90
|
/**
|
|
65
91
|
* Resolve the project key for a cwd **on this machine**: the repository it
|
|
66
92
|
* belongs to when there is one (so a monorepo subdir like `<repo>/apps/cli`
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type ShareConfig } from './config.js';
|
|
2
|
+
/** DI seam for tests — override the real HTTP DELETE. */
|
|
3
|
+
export type DeleteFn = (url: string, headers: Record<string, string>) => Promise<{
|
|
4
|
+
ok: boolean;
|
|
5
|
+
status: number;
|
|
6
|
+
}>;
|
|
7
|
+
/** DI seam for tests — override the real HTTP existence check (HEAD). */
|
|
8
|
+
export type CheckFn = (url: string) => Promise<{
|
|
9
|
+
status: number;
|
|
10
|
+
}>;
|
|
11
|
+
export interface DeleteEndpoint {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
token: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ResolvedShareTarget {
|
|
16
|
+
/** R2 object key for the page, `<user>/<slug>`. */
|
|
17
|
+
key: string;
|
|
18
|
+
/** R2 object key for the sibling OG cover, `<user>/<slug>.png`. */
|
|
19
|
+
coverKey: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Normalize any of the three accepted target forms to the R2 key that publish
|
|
23
|
+
* would have written:
|
|
24
|
+
* - a full share URL: `https://share.agents-cli.sh/<user>/<slug>`
|
|
25
|
+
* - `<user>/<slug>`
|
|
26
|
+
* - a bare `<slug>` — resolved against the caller's own namespace exactly as
|
|
27
|
+
* `publishToEndpoint` resolves it at publish time (resolveShareUsername +
|
|
28
|
+
* buildShareKey), so a bare slug always targets *your* published page.
|
|
29
|
+
*
|
|
30
|
+
* The URL and `<user>/<slug>` forms are taken as already the exact key a prior
|
|
31
|
+
* publish produced (no re-sanitizing) — only the bare-slug form runs through
|
|
32
|
+
* `buildShareKey`, because that's the one case where the slug hasn't already
|
|
33
|
+
* been normalized by a publish.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveDeleteTarget(target: string, opts?: {
|
|
36
|
+
githubUser?: string;
|
|
37
|
+
}): Promise<ResolvedShareTarget>;
|
|
38
|
+
export interface DeleteObjectResult {
|
|
39
|
+
key: string;
|
|
40
|
+
url: string;
|
|
41
|
+
/** Whether the object resolved (non-404) before the DELETE was issued. */
|
|
42
|
+
existedBefore: boolean;
|
|
43
|
+
/** Whether the Worker's DELETE call itself reported ok. */
|
|
44
|
+
deleted: boolean;
|
|
45
|
+
/** The postcondition: a follow-up check resolved 404 after the DELETE. */
|
|
46
|
+
verified404: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Delete one R2 object behind the share Worker and assert the postcondition.
|
|
50
|
+
* `{"ok":true}` from the Worker is not evidence a page came down — R2 delete is
|
|
51
|
+
* idempotent, so a DELETE on a key that was never there also returns `ok:true`.
|
|
52
|
+
* This checks existence before (so callers can tell "deleted" from "was never
|
|
53
|
+
* there") and re-checks after (so callers can tell "deleted" from "still public").
|
|
54
|
+
*/
|
|
55
|
+
export declare function deleteObject(endpoint: DeleteEndpoint, key: string, opts?: {
|
|
56
|
+
deleter?: DeleteFn;
|
|
57
|
+
checker?: CheckFn;
|
|
58
|
+
}): Promise<DeleteObjectResult>;
|
|
59
|
+
export interface DeleteShareOptions {
|
|
60
|
+
/** Skip deleting the sibling `<slug>.png` OG cover (default: delete it too). */
|
|
61
|
+
keepCover?: boolean;
|
|
62
|
+
/** Treat an already-missing target as a no-op success instead of an error
|
|
63
|
+
* (mirrors SQL's `DROP ... IF EXISTS`). Default: missing target is an error. */
|
|
64
|
+
ifExists?: boolean;
|
|
65
|
+
/** Override the GitHub username used to resolve a bare-slug target. */
|
|
66
|
+
githubUser?: string;
|
|
67
|
+
/** DI seam for tests — override the persisted share endpoint config. */
|
|
68
|
+
config?: ShareConfig;
|
|
69
|
+
/** DI seam for tests — override the keychain-backed write token. */
|
|
70
|
+
writeToken?: string;
|
|
71
|
+
/** DI seam for tests — override the real HTTP DELETE. */
|
|
72
|
+
deleter?: DeleteFn;
|
|
73
|
+
/** DI seam for tests — override the real HTTP existence check. */
|
|
74
|
+
checker?: CheckFn;
|
|
75
|
+
}
|
|
76
|
+
export interface DeleteShareResult {
|
|
77
|
+
key: string;
|
|
78
|
+
url: string;
|
|
79
|
+
existedBefore: boolean;
|
|
80
|
+
verified404: boolean;
|
|
81
|
+
/** True when `ifExists` was set and the target was already gone — nothing ran. */
|
|
82
|
+
skipped?: boolean;
|
|
83
|
+
cover?: {
|
|
84
|
+
key: string;
|
|
85
|
+
url: string;
|
|
86
|
+
existedBefore: boolean;
|
|
87
|
+
verified404: boolean;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Delete one share target (page + by default its OG cover) and verify both
|
|
91
|
+
* are gone. Throws on an unverified takedown — never reports success for an
|
|
92
|
+
* object that still resolves. */
|
|
93
|
+
export declare function deleteShare(target: string, opts?: DeleteShareOptions): Promise<DeleteShareResult>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The delete path for `agents share delete` / `agents unshare` — an authed DELETE
|
|
2
|
+
// to the Worker, which already implements it (worker-template.ts). Mirrors
|
|
3
|
+
// publish.ts: pure target-resolution logic is exported for tests, the network
|
|
4
|
+
// calls (a status check + a delete) sit behind an injectable DI seam.
|
|
5
|
+
//
|
|
6
|
+
// The Worker's R2 delete is idempotent — DELETE on a key that never existed still
|
|
7
|
+
// returns `{"ok":true}` — so `{"ok":true}` alone is never proof of a takedown.
|
|
8
|
+
// Every delete here is followed by a status check that must observe 404 before
|
|
9
|
+
// the operation is reported as successful.
|
|
10
|
+
import { readShareConfig, readWriteToken } from './config.js';
|
|
11
|
+
import { buildShareKey, resolveShareUsername } from './publish.js';
|
|
12
|
+
/**
|
|
13
|
+
* Normalize any of the three accepted target forms to the R2 key that publish
|
|
14
|
+
* would have written:
|
|
15
|
+
* - a full share URL: `https://share.agents-cli.sh/<user>/<slug>`
|
|
16
|
+
* - `<user>/<slug>`
|
|
17
|
+
* - a bare `<slug>` — resolved against the caller's own namespace exactly as
|
|
18
|
+
* `publishToEndpoint` resolves it at publish time (resolveShareUsername +
|
|
19
|
+
* buildShareKey), so a bare slug always targets *your* published page.
|
|
20
|
+
*
|
|
21
|
+
* The URL and `<user>/<slug>` forms are taken as already the exact key a prior
|
|
22
|
+
* publish produced (no re-sanitizing) — only the bare-slug form runs through
|
|
23
|
+
* `buildShareKey`, because that's the one case where the slug hasn't already
|
|
24
|
+
* been normalized by a publish.
|
|
25
|
+
*/
|
|
26
|
+
export async function resolveDeleteTarget(target, opts = {}) {
|
|
27
|
+
const trimmed = target.trim();
|
|
28
|
+
if (!trimmed)
|
|
29
|
+
throw new Error('Share target is empty.');
|
|
30
|
+
let key;
|
|
31
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
32
|
+
const url = new URL(trimmed);
|
|
33
|
+
const segments = url.pathname
|
|
34
|
+
.replace(/^\/+|\/+$/g, '')
|
|
35
|
+
.split('/')
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.map(decodeURIComponent);
|
|
38
|
+
if (segments.length < 2) {
|
|
39
|
+
throw new Error(`Not a share page URL (expected .../<user>/<slug>): ${trimmed}`);
|
|
40
|
+
}
|
|
41
|
+
key = segments.slice(0, 2).join('/');
|
|
42
|
+
}
|
|
43
|
+
else if (trimmed.includes('/')) {
|
|
44
|
+
const segments = trimmed.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
45
|
+
if (segments.length !== 2) {
|
|
46
|
+
throw new Error(`Expected <user>/<slug>, got: ${trimmed}`);
|
|
47
|
+
}
|
|
48
|
+
key = segments.join('/');
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const username = await resolveShareUsername(opts);
|
|
52
|
+
key = buildShareKey(username, trimmed);
|
|
53
|
+
}
|
|
54
|
+
return { key, coverKey: `${key}.png` };
|
|
55
|
+
}
|
|
56
|
+
async function defaultCheck(url) {
|
|
57
|
+
const res = await fetch(url, { method: 'HEAD' });
|
|
58
|
+
return { status: res.status };
|
|
59
|
+
}
|
|
60
|
+
async function defaultDelete(url, headers) {
|
|
61
|
+
const res = await fetch(url, { method: 'DELETE', headers });
|
|
62
|
+
return { ok: res.ok, status: res.status };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Delete one R2 object behind the share Worker and assert the postcondition.
|
|
66
|
+
* `{"ok":true}` from the Worker is not evidence a page came down — R2 delete is
|
|
67
|
+
* idempotent, so a DELETE on a key that was never there also returns `ok:true`.
|
|
68
|
+
* This checks existence before (so callers can tell "deleted" from "was never
|
|
69
|
+
* there") and re-checks after (so callers can tell "deleted" from "still public").
|
|
70
|
+
*/
|
|
71
|
+
export async function deleteObject(endpoint, key, opts = {}) {
|
|
72
|
+
const url = `${endpoint.baseUrl.replace(/\/+$/, '')}/${key}`;
|
|
73
|
+
const check = opts.checker ?? defaultCheck;
|
|
74
|
+
const del = opts.deleter ?? defaultDelete;
|
|
75
|
+
const before = await check(url);
|
|
76
|
+
const existedBefore = before.status !== 404;
|
|
77
|
+
const r = await del(url, { authorization: `Bearer ${endpoint.token}` });
|
|
78
|
+
if (!r.ok) {
|
|
79
|
+
throw new Error(`Delete failed (${r.status}) for ${url}. Check the write token, or that 'agents share setup' completed.`);
|
|
80
|
+
}
|
|
81
|
+
const after = await check(url);
|
|
82
|
+
const verified404 = after.status === 404;
|
|
83
|
+
return { key, url, existedBefore, deleted: r.ok, verified404 };
|
|
84
|
+
}
|
|
85
|
+
/** Delete one share target (page + by default its OG cover) and verify both
|
|
86
|
+
* are gone. Throws on an unverified takedown — never reports success for an
|
|
87
|
+
* object that still resolves. */
|
|
88
|
+
export async function deleteShare(target, opts = {}) {
|
|
89
|
+
const cfg = opts.config ?? readShareConfig();
|
|
90
|
+
if (!cfg) {
|
|
91
|
+
throw new Error("Not set up yet. Run 'agents share setup' (provision your own endpoint) or 'agents share join' (use an existing one).");
|
|
92
|
+
}
|
|
93
|
+
const token = opts.writeToken ?? readWriteToken();
|
|
94
|
+
const resolved = await resolveDeleteTarget(target, { githubUser: opts.githubUser });
|
|
95
|
+
const endpoint = { baseUrl: cfg.baseUrl, token };
|
|
96
|
+
const page = await deleteObject(endpoint, resolved.key, { deleter: opts.deleter, checker: opts.checker });
|
|
97
|
+
if (!page.existedBefore) {
|
|
98
|
+
if (opts.ifExists) {
|
|
99
|
+
return { key: page.key, url: page.url, existedBefore: false, verified404: page.verified404, skipped: true };
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`Nothing to delete — ${page.url} was already not found. Pass --if-exists to treat this as a no-op instead of an error.`);
|
|
102
|
+
}
|
|
103
|
+
if (!page.verified404) {
|
|
104
|
+
throw new Error(`Delete reported success but ${page.url} still resolves — takedown NOT verified. Retry, or investigate the Worker/R2 directly.`);
|
|
105
|
+
}
|
|
106
|
+
const result = {
|
|
107
|
+
key: page.key,
|
|
108
|
+
url: page.url,
|
|
109
|
+
existedBefore: page.existedBefore,
|
|
110
|
+
verified404: page.verified404,
|
|
111
|
+
};
|
|
112
|
+
if (!opts.keepCover) {
|
|
113
|
+
const cover = await deleteObject(endpoint, resolved.coverKey, { deleter: opts.deleter, checker: opts.checker });
|
|
114
|
+
result.cover = {
|
|
115
|
+
key: cover.key,
|
|
116
|
+
url: cover.url,
|
|
117
|
+
existedBefore: cover.existedBefore,
|
|
118
|
+
verified404: cover.verified404,
|
|
119
|
+
};
|
|
120
|
+
// A missing cover is normal (non-HTML publishes, or --no-cover at publish
|
|
121
|
+
// time never made one) — only a cover that existed and is still up is a bug.
|
|
122
|
+
if (cover.existedBefore && !cover.verified404) {
|
|
123
|
+
throw new Error(`Cover delete reported success but ${cover.url} still resolves — takedown NOT verified. Retry, or pass --keep-cover and delete it manually.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
}
|
package/dist/lib/shims.js
CHANGED
|
@@ -236,6 +236,43 @@ function codexShimLaunchArgs() {
|
|
|
236
236
|
...codexPolicyArgs('edit'),
|
|
237
237
|
].map(shellQuote).join(' ');
|
|
238
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* The `exec` tail for a generated shim. For most agents this is a plain
|
|
241
|
+
* `exec "$BINARY"<launchArgs> "$@"`.
|
|
242
|
+
*
|
|
243
|
+
* Codex is special: its `workspace-write` sandbox hardcodes any `.agents/` (and
|
|
244
|
+
* `.codex/`) directory read-only, but agents-cli keeps every worktree at
|
|
245
|
+
* `<repo>/.agents/worktrees/<slug>`, so an in-repo build under a static
|
|
246
|
+
* shim would hit `EROFS`. The `agents run codex` path fixes this by adding
|
|
247
|
+
* `<repo>/.agents` to the profile's `workspace_roots` (see codexEditWritableRoots
|
|
248
|
+
* / repoAgentsDirForCwd), but a static shim has no cwd at generation time. So the
|
|
249
|
+
* shim resolves the repo's `.agents` from `$PWD` at RUN time — worktree-aware,
|
|
250
|
+
* mirroring repoAgentsDirForCwd — and passes it via Codex's own `--add-dir`
|
|
251
|
+
* (verified to compose with the `agents-edit` profile). The resolution is inline
|
|
252
|
+
* bash because the shim is the launch hot path and must not spawn Node to compute
|
|
253
|
+
* one directory. `--add-dir` is added only when the resolved `.agents` exists.
|
|
254
|
+
*/
|
|
255
|
+
function shimExecTail(agent, launchArgs) {
|
|
256
|
+
if (agent !== 'codex')
|
|
257
|
+
return `exec "$BINARY"${launchArgs} "$@"`;
|
|
258
|
+
return `_repo_agents=""
|
|
259
|
+
case "$PWD" in
|
|
260
|
+
*/.agents/worktrees/*) _repo_agents="\${PWD%%/.agents/worktrees/*}/.agents" ;;
|
|
261
|
+
*)
|
|
262
|
+
_d="$PWD"
|
|
263
|
+
# Stop before $HOME so a dotfiles repo at $HOME is not treated as the project
|
|
264
|
+
# root (mirrors repoRootForCwd's home exclusion in project-key.ts).
|
|
265
|
+
while [ -n "$_d" ] && [ "$_d" != "/" ] && [ "$_d" != "$HOME" ]; do
|
|
266
|
+
if [ -e "$_d/.git" ]; then _repo_agents="$_d/.agents"; break; fi
|
|
267
|
+
_d=$(dirname "$_d")
|
|
268
|
+
done
|
|
269
|
+
;;
|
|
270
|
+
esac
|
|
271
|
+
if [ -n "$_repo_agents" ] && [ -d "$_repo_agents" ]; then
|
|
272
|
+
exec "$BINARY"${launchArgs} --add-dir "$_repo_agents" "$@"
|
|
273
|
+
fi
|
|
274
|
+
exec "$BINARY"${launchArgs} "$@"`;
|
|
275
|
+
}
|
|
239
276
|
function getAgentsBinForGeneratedShim() {
|
|
240
277
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js');
|
|
241
278
|
}
|
|
@@ -707,7 +744,7 @@ if [ "\$LAUNCH_SKIP" = "0" ]; then
|
|
|
707
744
|
"\$AGENTS_BIN" sync --agent "\$AGENT" --agent-version "\$VERSION" --launch --cwd "\$PWD" --quiet 2>/dev/null || true
|
|
708
745
|
fi
|
|
709
746
|
|
|
710
|
-
|
|
747
|
+
${shimExecTail(agent, launchArgs)}
|
|
711
748
|
`;
|
|
712
749
|
}
|
|
713
750
|
/**
|
|
@@ -1094,7 +1131,7 @@ if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
|
|
|
1094
1131
|
fi
|
|
1095
1132
|
${managedEnv}
|
|
1096
1133
|
|
|
1097
|
-
|
|
1134
|
+
${shimExecTail(agent, launchArgs)}
|
|
1098
1135
|
`;
|
|
1099
1136
|
}
|
|
1100
1137
|
/**
|
|
@@ -268,6 +268,9 @@ export const COMMAND_LOADERS = {
|
|
|
268
268
|
mailbox: [loadMailboxes],
|
|
269
269
|
serve: [loadServe],
|
|
270
270
|
share: [loadShare],
|
|
271
|
+
// `unshare` is a top-level convenience alias of `share delete` (see
|
|
272
|
+
// commands/share.ts) — same module, registered as its own program.command().
|
|
273
|
+
unshare: [loadShare],
|
|
271
274
|
audit: [loadAudit],
|
|
272
275
|
webhook: [loadWebhook],
|
|
273
276
|
funnel: [loadFunnel],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.35",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|