@hiveku-apps/sync 0.3.5 → 0.4.0

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.
@@ -0,0 +1,253 @@
1
+ {
2
+ "source": "locus2012/hiveku-codex-plugin 0.1.16 (5b4e580), plugins/hiveku/.mcp.json: every hiveku.tools entry (all approval_mode \"prompt\"); plus project_vcs_rollback (Versions Wave 2, hiveku-codex-plugin feature/versions-wave2)",
3
+ "why": "hiveku-sync writes its own [mcp_servers.hiveku] into a Codex folder. Codex then uses that entry, not the plugin's, for the server's connection and for every tool's approval mode, so these prompts must be written here too. Refresh this list whenever the Codex plugin's prompt list changes.",
4
+ "tools": [
5
+ "deploy_site",
6
+ "deploy_run",
7
+ "project_files_bulk_save",
8
+ "project_vcs_commit",
9
+ "project_vcs_merge",
10
+ "project_vcs_pr_merge",
11
+ "project_vcs_env_bind",
12
+ "project_vcs_branch_delete",
13
+ "project_vcs_stash",
14
+ "project_vcs_prune",
15
+ "project_vcs_branch_preview_teardown",
16
+ "project_vcs_rollback",
17
+ "project_vcs_revert",
18
+ "hiveku_batch",
19
+ "project_checkpoint_restore",
20
+ "checkpoint_restore",
21
+ "history_restore_to_time",
22
+ "checkpoint_delete",
23
+ "site_delete",
24
+ "site_firewall_allow",
25
+ "site_firewall_remove",
26
+ "project_indexing_set",
27
+ "account_memory_append",
28
+ "memory_update",
29
+ "memory_delete",
30
+ "memory_restore_version",
31
+ "memory_bulk_create",
32
+ "content_publish_to_site",
33
+ "content_schedule",
34
+ "email_campaign_send_now",
35
+ "email_campaign_schedule",
36
+ "email_campaign_resend_non_openers",
37
+ "email_campaign_test_send",
38
+ "crm_envelope_send",
39
+ "crm_estimate_send",
40
+ "helpdesk_ticket_send_reply",
41
+ "marketing_report_send",
42
+ "ppc_budget_update",
43
+ "ppc_platform_budget_update",
44
+ "ppc_bid_modifier_update",
45
+ "ppc_keyword_bid_update",
46
+ "ppc_platform_keyword_bid_update",
47
+ "ppc_bidding_strategy_update",
48
+ "ppc_platform_bidding_strategy_update",
49
+ "ppc_campaign_create",
50
+ "ppc_google_auto_apply_set",
51
+ "ppc_google_campaign_settings_set",
52
+ "ppc_google_ad_schedule_set",
53
+ "ppc_google_audience_exclusions_set",
54
+ "ppc_google_call_settings_set",
55
+ "ppc_google_campaign_ai_settings_set",
56
+ "ppc_bing_campaign_ai_settings_set",
57
+ "ppc_google_ad_text_update",
58
+ "ppc_google_ads_text_replace",
59
+ "ppc_negatives_remove",
60
+ "ppc_bing_url_tracking_set",
61
+ "ppc_bing_ad_extension_update",
62
+ "ppc_bing_ad_extension_remove",
63
+ "ppc_bing_experiment_create",
64
+ "ppc_bing_experiment_update",
65
+ "ppc_experiment_promote",
66
+ "ppc_experiment_treatment_set",
67
+ "ppc_conversion_adjustments_run",
68
+ "ppc_conversion_adjustments_set",
69
+ "ppc_meta_lead_quality_test",
70
+ "ppc_experiment_schedule",
71
+ "ppc_experiment_graduate",
72
+ "ppc_offline_conversion_upload",
73
+ "ppc_customer_match_upload",
74
+ "ppc_goals_set",
75
+ "ppc_budget_target_set",
76
+ "ppc_connection_update",
77
+ "agent_approval_approve",
78
+ "seo_ga4_key_event_update",
79
+ "seo_ga4_event_create_rule_update",
80
+ "accounting_bill_record_payment",
81
+ "accounting_invoice_record_payment",
82
+ "crm_contact_email_send",
83
+ "crm_enroll_sequence",
84
+ "crm_sequence_enroll_bulk",
85
+ "email_sequence_enroll",
86
+ "email_sequence_activate",
87
+ "voice_sms_send",
88
+ "voice_sms_send_to_contact",
89
+ "voice_sms_thread_reply",
90
+ "crm_remove_dnc",
91
+ "outbound_campaign_status_set",
92
+ "outbound_campaign_sequences_save",
93
+ "outbound_reply_draft_send",
94
+ "voice_number_purchase",
95
+ "voice_number_release",
96
+ "voice_e911_address_create",
97
+ "voice_extension_delete",
98
+ "voice_ivr_create",
99
+ "voice_ivr_update",
100
+ "voice_ivr_delete",
101
+ "voice_ring_group_delete",
102
+ "voice_queue_delete",
103
+ "voice_pool_delete",
104
+ "voice_call_tracking_setup",
105
+ "voice_call_originate",
106
+ "voice_sms_bulk_send",
107
+ "voice_sms_brand_submit",
108
+ "voice_sms_campaign_submit",
109
+ "voice_sms_campaign_resubmit",
110
+ "voice_sms_campaign_delete",
111
+ "voice_sms_number_assign_campaign",
112
+ "voice_sms_toll_free_verification_submit",
113
+ "voice_port_order_create",
114
+ "voice_port_order_action",
115
+ "voice_settings_update",
116
+ "voice_number_update",
117
+ "voice_blocked_numbers_remove",
118
+ "voice_tenant_repair",
119
+ "voice_swap_test",
120
+ "voice_call_tracking_live_probe",
121
+ "voice_phone_tracking_config_set",
122
+ "voice_phone_tracking_config_delete",
123
+ "voice_pool_update",
124
+ "voice_pool_create",
125
+ "voice_pool_numbers_add",
126
+ "voice_pool_numbers_remove",
127
+ "voice_pool_e911_apply",
128
+ "voice_webhook_create",
129
+ "voice_webhook_update",
130
+ "voice_webhook_delete",
131
+ "voice_sms_registration_share_link_create",
132
+ "voice_sms_registration_share_link_revoke",
133
+ "voice_port_order_share_link_create",
134
+ "voice_port_order_share_link_revoke",
135
+ "voice_ivrs_reprovision",
136
+ "voice_ai_agent_config_update",
137
+ "voice_number_cnam_set",
138
+ "voice_sms_campaign_appeal",
139
+ "voice_port_order_verification_codes_send",
140
+ "voice_sms_scheduled_cancel",
141
+ "voice_numbers_search",
142
+ "seo_gbp_review_reply",
143
+ "seo_gbp_review_reply_delete",
144
+ "seo_gbp_location_update",
145
+ "seo_gbp_attributes_update",
146
+ "seo_gsc_delete_sitemap",
147
+ "seo_report_clear",
148
+ "seo_deliverable_delete",
149
+ "seo_task_implement",
150
+ "seo_connection_delete",
151
+ "seo_gbp_services_update",
152
+ "seo_gbp_media_add",
153
+ "seo_gbp_media_delete",
154
+ "seo_gtm_publish",
155
+ "seo_gtm_install",
156
+ "seo_ga4_key_event_delete",
157
+ "seo_ga4_event_create_rule_delete",
158
+ "seo_tracking_project_delete",
159
+ "seo_rankings_platforms_set",
160
+ "seo_listings_scan",
161
+ "seo_connection_test",
162
+ "seo_competitor_delete",
163
+ "seo_keyword_cluster_delete",
164
+ "seo_topic_cluster_delete",
165
+ "seo_backlink_tracker_delete",
166
+ "seo_backlink_opportunity_delete",
167
+ "seo_automated_report_delete",
168
+ "seo_page_schema_delete",
169
+ "workflow_run",
170
+ "workflow_enable",
171
+ "workflow_trigger_update",
172
+ "workflow_delete",
173
+ "workflow_delete_schedule",
174
+ "workflow_stranded_replay",
175
+ "workflow_run_replay",
176
+ "workflow_run_retry",
177
+ "workflow_dead_letter_resolve",
178
+ "workflow_set_recipient",
179
+ "hiveboard_restore_version",
180
+ "hiveboard_delete",
181
+ "hiveboard_elements_prune",
182
+ "customer_avatar_version_restore",
183
+ "before_after_grid_version_restore",
184
+ "customer_journey_version_restore",
185
+ "customer_avatar_delete",
186
+ "customer_journey_delete",
187
+ "before_after_grid_delete",
188
+ "customer_journey_stage_delete",
189
+ "media_delete",
190
+ "media_folder_delete",
191
+ "brand_guide_delete",
192
+ "brand_guide_purge",
193
+ "marketing_storyboard_delete",
194
+ "marketing_generate_video",
195
+ "marketing_video_pipeline_retry_scene",
196
+ "design_voiceover_create",
197
+ "generate_image_set",
198
+ "media_upscale",
199
+ "marketing_testimonial_media_replace",
200
+ "marketing_form_upload_settings_update",
201
+ "marketing_form_capture_settings_update",
202
+ "marketing_form_capture_purge",
203
+ "social_publish_post",
204
+ "social_delete_post",
205
+ "social_comment_reply",
206
+ "social_linkedin_comment_add",
207
+ "social_linkedin_reaction_add",
208
+ "social_linkedin_comment_delete",
209
+ "social_post_reject",
210
+ "social_pillar_delete",
211
+ "social_hashtags_delete",
212
+ "social_calendar_delete",
213
+ "social_schedule_slot_delete",
214
+ "social_post_retry",
215
+ "webflow_project_cms_provider_set",
216
+ "webflow_site_publish",
217
+ "webflow_cms_item_publish",
218
+ "webflow_cms_item_unpublish",
219
+ "webflow_cms_item_delete",
220
+ "webflow_page_customcode_set",
221
+ "webflow_page_customcode_delete",
222
+ "webflow_cms_collection_delete",
223
+ "webflow_cms_field_delete",
224
+ "webflow_cms_item_unpublish_bulk",
225
+ "webflow_cms_item_delete_bulk",
226
+ "webflow_asset_delete",
227
+ "webflow_site_customcode_set",
228
+ "webflow_site_customcode_delete",
229
+ "webflow_hiveku_snippet_install",
230
+ "webflow_google_tag_upsert",
231
+ "webflow_google_tag_delete",
232
+ "webflow_form_submission_delete",
233
+ "webflow_webhook_create",
234
+ "webflow_webhook_update",
235
+ "webflow_webhook_delete",
236
+ "webflow_order_fulfill",
237
+ "webflow_order_refund",
238
+ "webflow_redirect_create",
239
+ "webflow_redirect_delete",
240
+ "webflow_robots_update",
241
+ "webflow_robots_replace",
242
+ "webflow_robots_delete",
243
+ "webflow_wellknown_create",
244
+ "webflow_wellknown_delete",
245
+ "webflow_llms_txt_set",
246
+ "webflow_llms_txt_delete",
247
+ "cms_publish",
248
+ "cms_delete_entry",
249
+ "cms_bulk_delete",
250
+ "cms_delete_collection",
251
+ "cms_remove_field"
252
+ ]
253
+ }
package/src/codex.mjs CHANGED
@@ -17,8 +17,11 @@
17
17
  * the OLYMPUS_API_KEY env var). git-ignored.
18
18
  * skill/command knowledge → .agents/skills/<name>/SKILL.md (repo-scoped; Codex
19
19
  * prompts are global-only + deprecated)
20
- * permissions → NO per-repo equivalent (Codex ignores approval/
21
- * sandbox keys in repo config) — stated in AGENTS.md.
20
+ * permissions → the hiveku server's per-tool approvals: a FIXED
21
+ * list of high-risk tools asks first
22
+ * (CODEX_PROMPT_TOOLS below), every other tool runs
23
+ * without asking. The safe-work rules are stated
24
+ * as instructions in AGENTS.md, for every tool.
22
25
  *
23
26
  * This complements the published Hiveku Codex plugin
24
27
  * (github.com/locus2012/hiveku-codex-plugin): the plugin ships the generic
@@ -29,27 +32,94 @@
29
32
  */
30
33
 
31
34
  import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
32
- import { existsSync } from 'node:fs';
35
+ import { existsSync, readFileSync } from 'node:fs';
33
36
  import { homedir } from 'node:os';
34
37
  import { join, resolve, dirname } from 'node:path';
35
38
 
39
+ import { formatKey, readTomlDeclarations } from './toml.mjs';
36
40
  import { loadDotenv, safeSlug } from './util.mjs';
37
41
 
42
+ /**
43
+ * The FIXED list of high-risk Hiveku tools that Codex asks about before
44
+ * running. It is the Hiveku Codex plugin's own prompt list, copied name for
45
+ * name (codex-prompt-tools.json says which release; 246 names at plugin
46
+ * 0.1.16, plus project_vcs_rollback from Versions Wave 2). It includes hiveku_batch, because Codex asks by the name of the tool
47
+ * it calls and a batch carries other tools.
48
+ *
49
+ * It is a list of names, not a category. Every tool not on it runs without
50
+ * asking under the "approve" default, and that includes many deletes and sends
51
+ * (at MCP d4a73ba6d, for example crm_delete_contact, content_delete,
52
+ * assets_delete, survey_send and accounting_invoice_send). That is why
53
+ * AGENTS.md keeps the safe-work rules as instructions for every tool.
54
+ *
55
+ * A listed tool does not always ask either. Codex checks the run mode before
56
+ * it reads a tool's approval_mode (codex-mcp mcp_permission_prompt_is_auto_approved):
57
+ * - approval policy "never" (what `codex exec` sets) with a read-only or
58
+ * workspace-write sandbox: nobody can answer, so a listed call is
59
+ * cancelled and the rest run;
60
+ * - approval policy "never" with no sandbox or full disk write
61
+ * (--dangerously-bypass-approvals-and-sandbox, i.e. --yolo, or
62
+ * danger-full-access in `codex exec`): every prompt is skipped, these
63
+ * included, and every Hiveku tool runs unattended.
64
+ *
65
+ * Why this folder has to write it: the folder's .codex/config.toml defines
66
+ * [mcp_servers.hiveku]. Codex 0.131 then drops the plugin's server of the same
67
+ * name (core/src/config/mod.rs: configured servers win, plugin servers are
68
+ * only `or_insert`ed) and takes each tool's approval mode from the config
69
+ * layers alone (core/src/mcp_tool_call.rs custom_mcp_tool_approval_mode:
70
+ * tools.<tool>.approval_mode, else default_tools_approval_mode; the plugin is
71
+ * read only when no config layer names the server). With only
72
+ * default_tools_approval_mode = "approve" here, every Hiveku tool ran without
73
+ * a prompt, with or without the plugin installed.
74
+ *
75
+ * Adding another prompt list: put its names into this one Set, so each tool
76
+ * still gets exactly one table. A second block of tables would define some
77
+ * tables twice, and that makes the whole config.toml invalid TOML. (Versions
78
+ * Wave 2 did this: its five roll-back tools are in codex-prompt-tools.json,
79
+ * project_vcs_rollback included.)
80
+ *
81
+ * The user's own table wins: a listed tool that already has its own
82
+ * [mcp_servers.hiveku.tools.<tool>] table outside the managed block gets no
83
+ * table from us (userToolTables below), and writeCodexScaffold writes nothing
84
+ * when the block would still declare a table the file declares (blockConflict).
85
+ */
86
+ export const CODEX_PROMPT_TOOLS = [
87
+ ...new Set(JSON.parse(readFileSync(new URL('./codex-prompt-tools.json', import.meta.url), 'utf8')).tools),
88
+ ];
89
+
90
+ /** A TOML bare key when the name allows it, a quoted key otherwise. */
91
+ function tomlKey(name) {
92
+ return /^[A-Za-z0-9_-]+$/.test(name) ? name : JSON.stringify(name);
93
+ }
94
+
38
95
  const AGENTS_BEGIN = '<!-- hiveku:begin -->';
39
96
  const AGENTS_END = '<!-- hiveku:end -->';
40
97
  const TOML_BEGIN = '# hiveku:begin (managed by hiveku-sync — do not edit inside)';
41
98
  const TOML_END = '# hiveku:end';
42
99
  const SKILL_STAMP = '<!-- generated-by: hiveku-sync (mirrored from account knowledge) -->';
43
100
 
44
- /** Replace (or append) the marked region inside existing content. */
45
- function upsertMarkedRegion(existing, begin, end, region) {
101
+ /**
102
+ * Replace (or append) the marked region inside existing content. Also says
103
+ * where: `start` is the region's offset in `text`, and `replaced` the old
104
+ * region it replaced (null when it was appended).
105
+ */
106
+ function placeMarkedRegion(existing, begin, end, region) {
46
107
  const b = existing.indexOf(begin);
47
108
  const e = existing.indexOf(end);
48
109
  if (b !== -1 && e !== -1 && e > b) {
49
- return existing.slice(0, b) + region + existing.slice(e + end.length);
110
+ return {
111
+ text: existing.slice(0, b) + region + existing.slice(e + end.length),
112
+ start: b,
113
+ replaced: existing.slice(b, e + end.length),
114
+ };
50
115
  }
51
116
  const sep = existing.length && !existing.endsWith('\n\n') ? (existing.endsWith('\n') ? '\n' : '\n\n') : '';
52
- return existing + sep + region + '\n';
117
+ return { text: existing + sep + region + '\n', start: existing.length + sep.length, replaced: null };
118
+ }
119
+
120
+ /** Replace (or append) the marked region inside existing content. */
121
+ function upsertMarkedRegion(existing, begin, end, region) {
122
+ return placeMarkedRegion(existing, begin, end, region).text;
53
123
  }
54
124
 
55
125
  /** AGENTS.md — lean on purpose (Codex caps the instruction chain at ~32KiB). */
@@ -78,6 +148,13 @@ Non-negotiables (also in CLAUDE.md, restated because they are load-bearing):
78
148
  — \`changed\` = they edited it, \`only_remote\` = they added files you lack). Before any tree-replace
79
149
  (\`delete_missing: true\`), \`dry_run\` first and READ the would-delete list — a file you did not send
80
150
  may be someone else's NEW work. Never blind-overwrite.
151
+ - **Version every change.** After saving and verifying, call \`project_vcs_commit({ project_id, message })\`
152
+ with NO files, once per change, before \`deploy_site\` (or \`hiveku-sync push -m "..."\`, which does both);
153
+ 409 \`nothing_to_commit\` means it is already a version: not an error.
154
+ The name is plain language for the site owner ("Updated the pricing section on the Home page"): never file
155
+ paths, extensions, \`fix:\` prefixes or tool names. Roll back with \`project_vcs_rollback\`: dry run first,
156
+ apply only after the user agrees, with the dry run's \`head_commit_id\` as \`expected_head_commit_id\` (on Your
157
+ site also its \`live_fingerprint\` as \`expected_live_fingerprint\`); publishing is a separate \`deploy_site\`.
81
158
  - **Keep ALL scratch work in \`.hiveku/tmp/\`** — never \`/tmp\` (shared across accounts), your home
82
159
  dir, or the repo root.
83
160
  - **Never ingest local agent config into a project push.** \`.codex/config.toml\`, \`.mcp.json\`, and
@@ -94,13 +171,117 @@ Non-negotiables (also in CLAUDE.md, restated because they are load-bearing):
94
171
  - Video generation is paid + capped — \`marketing_generate_video\` with \`dry_run: true\` first.
95
172
 
96
173
  Discover tools with \`hiveku_docs_search\` / \`hiveku_docs_get\` and \`hiveku_playbooks_list\` /
97
- \`hiveku_playbook_get\`. Codex ignores repo-level approval/sandbox config, so the rules above are
98
- instructions, not enforced permissions — follow them.
174
+ \`hiveku_playbook_get\`. Codex asks the user first only for a fixed list of ${CODEX_PROMPT_TOOLS.length} high-risk Hiveku
175
+ tools (hiveku-sync's \`codex-prompt-tools.json\`, the Hiveku Codex plugin's list, written as per-tool
176
+ prompts in \`.codex/config.toml\`). Every other Hiveku tool, including many deletes and sends, runs
177
+ without asking, and some Codex modes (\`--yolo\`, \`danger-full-access\`) skip even those prompts. So
178
+ never count on a prompt: the rules above are instructions, not enforced permissions — follow them for
179
+ every tool.
99
180
  ${AGENTS_END}`;
100
181
  }
101
182
 
183
+ /**
184
+ * The listed tools whose table the user's own settings already declare
185
+ * outside the managed block, in list order. Writing ours as well would
186
+ * declare that table twice, which TOML does not allow, so theirs wins.
187
+ *
188
+ * A tool's table is declared by a [mcp_servers.hiveku.tools.<tool>] or
189
+ * [[...]] header, however it is spelled (quoted parts, spaces), or by a key
190
+ * that sets it from a table above it: `<tool> = { ... }` or
191
+ * `<tool>.approval_mode = ...` under [mcp_servers.hiveku.tools]. A header one
192
+ * level deeper ([...tools.<tool>.x]) does not declare it, so it does not count.
193
+ * The file is read as Codex reads it (TOML 1.1 syntax, a byte-order mark at
194
+ * the start); a line that cannot be read is skipped.
195
+ *
196
+ * `withoutApprovalMode`: those whose own table sets no approval_mode, so
197
+ * Codex uses the server default ("approve") for them.
198
+ */
199
+ function userToolTables(existing) {
200
+ const text = existing.replace(/\r\n/g, '\n');
201
+ const begin = text.indexOf(TOML_BEGIN);
202
+ const end = text.indexOf(TOML_END);
203
+ const blockEnd = begin !== -1 && end > begin ? end + TOML_END.length : -1;
204
+ const found = new Map(); // tool -> { declared, setsMode, notATable }
205
+ for (const statement of readTomlDeclarations(text).statements) {
206
+ if (blockEnd !== -1 && statement.pos >= begin && statement.pos < blockEnd) continue; // ours
207
+ const keyval = statement.type === 'keyval';
208
+ const key = keyval ? [...statement.header, ...statement.key] : statement.key;
209
+ if (key.length < 4 || key[0] !== 'mcp_servers' || key[1] !== 'hiveku' || key[2] !== 'tools') continue;
210
+ const entry = found.get(key[3]) ?? { declared: false, setsMode: false, notATable: false };
211
+ if (keyval ? statement.header.length < 4 : key.length === 4) entry.declared = true;
212
+ if (key[4] === 'approval_mode') entry.setsMode = true;
213
+ if (keyval && key.length === 4) {
214
+ if (statement.value !== 'table') entry.notATable = true;
215
+ else if (statement.tableKeys.includes('approval_mode')) entry.setsMode = true;
216
+ }
217
+ if (statement.type === 'arrayTable' && key.length === 4) entry.notATable = true;
218
+ found.set(key[3], entry);
219
+ }
220
+ const tools = CODEX_PROMPT_TOOLS.filter((tool) => found.get(tool)?.declared);
221
+ const withoutApprovalMode = tools.filter((tool) => !found.get(tool).setsMode && !found.get(tool).notATable);
222
+ return { tools, withoutApprovalMode };
223
+ }
224
+
225
+ /**
226
+ * Where the update would declare a table or key twice with one of the two in
227
+ * hiveku-sync's own block: the one way writing the block can break a file.
228
+ * Returns the first such place as { ours, line }, `ours` being the block's
229
+ * statement and `line` the other declaration's line in `existing` (the file
230
+ * as it is now), or null. A problem in the rest of the file is the user's own
231
+ * and not a reason to stop: the block is written as before.
232
+ *
233
+ * `placed` is placeMarkedRegion's result for `region` in `existing`.
234
+ */
235
+ function blockConflict(existing, placed, region) {
236
+ const normalize = (text) => text.replace(/\r\n/g, '\n');
237
+ const start = normalize(placed.text.slice(0, placed.start)).length;
238
+ const end = start + region.length;
239
+ const inBlock = (statement) => statement !== null && statement.pos >= start && statement.pos < end;
240
+ // Text before the block is as it was; text after it moved by the old block's length less the new one's.
241
+ const before = normalize(existing);
242
+ const oldLength = placed.replaced === null ? 0 : normalize(placed.replaced).length;
243
+ const lineNow = (pos) => before.slice(0, pos < start ? pos : pos - end + start + oldLength).split('\n').length;
244
+ for (const { statement, prior } of readTomlDeclarations(normalize(placed.text)).conflicts) {
245
+ if (!inBlock(statement) && !inBlock(prior)) continue;
246
+ const theirs = inBlock(statement) ? prior : statement;
247
+ return { ours: inBlock(statement) ? statement : prior, line: theirs && !inBlock(theirs) ? lineNow(theirs.pos) : null };
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /**
253
+ * The error for an update blockConflict refused. It names the block's own
254
+ * table or key (hiveku-sync's names) and only the line number of the user's,
255
+ * so nothing the user typed is repeated (a pasted secret, say).
256
+ */
257
+ function refusalMessage(tomlPath, existing, { ours, line }) {
258
+ const name =
259
+ ours.type === 'keyval' ? `the key ${formatKey([...ours.header, ...ours.key])}` : `the table [${formatKey(ours.key)}]`;
260
+ const where = line ? `line ${line} of it` : 'a line of it';
261
+ const lines = [
262
+ `${tomlPath} was not updated: ${where} conflicts with ${name} in the hiveku-sync block, and a TOML file may declare each table and key only once. Nothing in the folder was changed.`,
263
+ ];
264
+ const begin = existing.indexOf(TOML_BEGIN);
265
+ if (begin !== -1 && existing.indexOf(TOML_BEGIN, begin + 1) !== -1) {
266
+ lines.push('The file has more than one "# hiveku:begin" line, so hiveku-sync could not tell which block is its own. Keep one block, then run `hiveku-sync codex` again.');
267
+ } else if (begin !== -1 && !(existing.indexOf(TOML_END) > begin)) {
268
+ lines.push('hiveku-sync could not find its own block to replace: the "# hiveku:begin" line has no "# hiveku:end" line after it. Put "# hiveku:end" back at the end of the block, then run `hiveku-sync codex` again.');
269
+ } else {
270
+ lines.push(`Remove or change ${line ? `line ${line}` : 'that line'}, then run \`hiveku-sync codex\` again.`);
271
+ }
272
+ return lines.join(' ');
273
+ }
274
+
275
+ /** A TOML basic string holding `value`: quotes, backslashes and control characters other than tab escaped. */
276
+ function tomlString(value) {
277
+ const escaped = value.replace(/["\\\x00-\x08\x0a-\x1f\x7f]/g, (ch) =>
278
+ ch === '"' || ch === '\\' ? `\\${ch}` : `\\u${ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}`,
279
+ );
280
+ return `"${escaped}"`;
281
+ }
282
+
102
283
  /** The [mcp_servers.*] region for <folder>/.codex/config.toml. */
103
- function codexTomlRegion({ baseUrl, profile, apiKey }) {
284
+ function codexTomlRegion({ baseUrl, profile, apiKey, userTables = [] }) {
104
285
  const base = baseUrl.replace(/\/+$/, '');
105
286
  const url = profile === 'full' ? `${base}/mcp` : `${base}/mcp?profile=${encodeURIComponent(profile)}`;
106
287
  // Prefer an inlined key (self-contained, no env setup needed; the file is
@@ -113,20 +294,38 @@ function codexTomlRegion({ baseUrl, profile, apiKey }) {
113
294
  // to bearer_token_env_var). TOML allows the key once per table, so the
114
295
  // client label rides in the same inline table as an inlined key.
115
296
  // X-Hiveku-Client: codex is what the MCP server's memory log shows as
116
- // "via Codex"; it is a label, never authentication.
297
+ // "via Codex"; it is a label, never authentication. The key and the URL come
298
+ // from .env, so they are escaped: a quote in either would otherwise end the
299
+ // string early and leave the block itself unreadable.
117
300
  const auth = apiKey
118
- ? `http_headers = { "Authorization" = "Bearer ${apiKey}", "X-Hiveku-Client" = "codex" }`
301
+ ? `http_headers = { "Authorization" = ${tomlString(`Bearer ${apiKey}`)}, "X-Hiveku-Client" = "codex" }`
119
302
  : `bearer_token_env_var = "OLYMPUS_API_KEY"\nhttp_headers = { "X-Hiveku-Client" = "codex" }`;
120
303
  // default_tools_approval_mode = "approve": this folder exists so the agent
121
- // can operate the account — per-call approval prompts would also auto-cancel
122
- // every tool call in headless `codex exec` (CI/cron), this lane's core use.
123
- // (Enum is approve|prompt; the docs' "auto" is not accepted by codex 0.131.)
304
+ // can operate the account — per-call approval prompts would also cancel
305
+ // every tool call in headless `codex exec` (CI/cron, this lane's core use)
306
+ // under a read-only or workspace-write sandbox.
307
+ // The per-tool tables after it override that default for CODEX_PROMPT_TOOLS
308
+ // (a tool's own approval_mode wins over the server default), so those ask
309
+ // first, exactly as they do under the Hiveku Codex plugin, in the modes where
310
+ // Codex asks at all (see CODEX_PROMPT_TOOLS). Each tool gets ONE table: a
311
+ // table defined twice makes the whole file invalid TOML, so merge any other
312
+ // prompt list into CODEX_PROMPT_TOOLS instead of adding tables here, and
313
+ // leave out the tools whose table the user declares outside this block
314
+ // (userTables): their own setting wins.
315
+ const skip = new Set(userTables);
316
+ const promptTables = CODEX_PROMPT_TOOLS.filter((tool) => !skip.has(tool))
317
+ .map((tool) => `[mcp_servers.hiveku.tools.${tomlKey(tool)}]\napproval_mode = "prompt"`)
318
+ .join('\n');
124
319
  return `${TOML_BEGIN}
125
320
  [mcp_servers.hiveku]
126
- url = "${url}"
321
+ url = ${tomlString(url)}
127
322
  ${auth}
128
323
  default_tools_approval_mode = "approve"
129
324
 
325
+ # Ask first: a fixed list of high-risk tools (the Hiveku Codex plugin's list).
326
+ # Every other hiveku tool runs without asking.
327
+ ${promptTables}
328
+
130
329
  [mcp_servers.playwright]
131
330
  command = "npx"
132
331
  args = ["-y", "@playwright/mcp@latest"]
@@ -211,6 +410,10 @@ export async function mirrorKnowledgeToSkills(folder) {
211
410
  * .codex/config.toml (git-ignored) + mirror the account's knowledge to skills.
212
411
  * Resolves OLYMPUS_API_KEY from the folder's .env / process env to inline the
213
412
  * MCP auth when possible.
413
+ *
414
+ * Throws, with nothing in the folder written, when the hiveku-sync block would
415
+ * declare a table or key that the rest of config.toml also declares (see
416
+ * blockConflict). The updated file is checked in memory, before any write.
214
417
  */
215
418
  export async function writeCodexScaffold({ folder, slug, baseUrl, profile }) {
216
419
  // The FOLDER's .env wins over a process-level export: folder = tenant
@@ -222,6 +425,24 @@ export async function writeCodexScaffold({ folder, slug, baseUrl, profile }) {
222
425
  dotenv.OLYMPUS_API_KEY && dotenv.OLYMPUS_API_KEY !== 'olp_replace_me' ? dotenv.OLYMPUS_API_KEY : '';
223
426
  const apiKey = dotenvKey || (existsSync(join(folder, '.env')) ? '' : process.env.OLYMPUS_API_KEY || '');
224
427
 
428
+ // .codex/config.toml (marker-managed region; rest of user's config untouched).
429
+ // First, and checked in memory before anything is written, so that a refused
430
+ // update leaves the whole folder as it was (no .codex/ folder created either).
431
+ const tomlPath = join(folder, '.codex', 'config.toml');
432
+ let toml = '';
433
+ try {
434
+ toml = await readFile(tomlPath, 'utf8');
435
+ } catch (err) {
436
+ if (err.code !== 'ENOENT') throw err;
437
+ }
438
+ const userTables = userToolTables(toml);
439
+ const region = codexTomlRegion({ baseUrl, profile, apiKey, userTables: userTables.tools });
440
+ const placed = placeMarkedRegion(toml, TOML_BEGIN, TOML_END, region);
441
+ const conflict = blockConflict(toml, placed, region);
442
+ if (conflict) throw new Error(refusalMessage(tomlPath, toml, conflict));
443
+ await mkdir(dirname(tomlPath), { recursive: true });
444
+ await writeFile(tomlPath, placed.text, 'utf8');
445
+
225
446
  // AGENTS.md (marker-managed)
226
447
  const agentsPath = join(folder, 'AGENTS.md');
227
448
  let agents = '';
@@ -232,17 +453,6 @@ export async function writeCodexScaffold({ folder, slug, baseUrl, profile }) {
232
453
  }
233
454
  await writeFile(agentsPath, upsertMarkedRegion(agents, AGENTS_BEGIN, AGENTS_END, agentsMdRegion({ slug })), 'utf8');
234
455
 
235
- // .codex/config.toml (marker-managed region; rest of user's config untouched)
236
- const tomlPath = join(folder, '.codex', 'config.toml');
237
- let toml = '';
238
- try {
239
- toml = await readFile(tomlPath, 'utf8');
240
- } catch {
241
- /* new */
242
- }
243
- await mkdir(dirname(tomlPath), { recursive: true });
244
- await writeFile(tomlPath, upsertMarkedRegion(toml, TOML_BEGIN, TOML_END, codexTomlRegion({ baseUrl, profile, apiKey })), 'utf8');
245
-
246
456
  // Gitignore EVERY credential-bearing file this lane depends on — not just the
247
457
  // one we write. The retrofit reads .env for OLYMPUS_API_KEY to inline it, so a
248
458
  // folder that had no .gitignore would otherwise end up with one that ignores
@@ -253,7 +463,14 @@ export async function writeCodexScaffold({ folder, slug, baseUrl, profile }) {
253
463
  await appendGitignoreLines(folder, ['.codex/config.toml', '.env', '.env.local', '.hiveku/']);
254
464
 
255
465
  const skills = await mirrorKnowledgeToSkills(folder);
256
- return { inlinedKey: !!apiKey, skillsMirrored: skills };
466
+ // userToolTables: listed tools left to the user's own table (the CLI names
467
+ // them); userTablesWithoutApprovalMode: those whose table sets no approval_mode.
468
+ return {
469
+ inlinedKey: !!apiKey,
470
+ skillsMirrored: skills,
471
+ userToolTables: userTables.tools,
472
+ userTablesWithoutApprovalMode: userTables.withoutApprovalMode,
473
+ };
257
474
  }
258
475
 
259
476
  /**